Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,28 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
config visitors (it now calls `load_config_yaml` internally so `apl:`
blocks are walked). The Go binding's `expectedFFIABIVersion` is bumped
in lockstep.
- Size-first `[profile.release]`: `opt-level = "z"`, `lto = true`,
`codegen-units = 1`, `strip = true`. `libcpex_ffi.a` is linked statically
into host binaries, so this flows straight into their image size — a
representative statically-linked consumer shrank ~21%. `panic = "abort"`
is intentionally not set (the FFI relies on `catch_unwind` at its
`#[no_mangle]` boundary). No API or ABI change.
- Trimmed the workspace `tokio` feature floor from `["full"]` to
`["rt", "rt-multi-thread", "sync", "time", "macros"]` — the union of what
the crates actually use; `reqwest`/`hyper` still pull `net`/`io` where they
need them via feature unification. Drops the unused `fs`/`process`/`signal`
surface (and the `signal-hook-registry` dependency).

### Fixed

- Cedar evaluation no longer fails with "recursion limit reached" on hosts
that give the FFI a small thread stack (notably musl, whose default is
128 KiB). `cedar-policy` aborts when `stacker::remaining_stack()` is below
its 100 KiB floor; the cedar dispatch in `apl-pdp-cedar-direct` is now
wrapped in `stacker::maybe_grow`, so it runs on an adequately sized stack
regardless of the host (a no-op when there is already headroom, e.g.
glibc's 8 MiB threads). Regression test exercises a real evaluation on a
128 KiB stack.

## [0.1.0] - 2026-05-05

Expand Down
23 changes: 2 additions & 21 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 23 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@ license = "Apache-2.0"
authors = ["Teryl Taylor", "Fred Araujo"]

[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
# Minimal tokio feature floor for crates that take `tokio = { workspace = true }`
# without their own `features`. Production code uses time, sync, task/spawn
# (rt) and a multi-threaded runtime (cpex-ffi's `new_multi_thread`); `net` and
# `io` appear only in tests, and reqwest/hyper union those back in via feature
# unification where they are actually needed. Dropping `full` removes the
# fs/process/signal/io-std surface the embedded library never exercises.
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
tokio-util = { version = "0.7", features = ["rt"] }
serde = { version = "1", features = ["derive", "rc"] }
serde_yaml = "0.9"
Expand All @@ -77,3 +83,19 @@ rmp-serde = "1"
serde_bytes = "0.11"
chrono = { version = "0.4", features = ["serde"] }
regex = "1"

# Size-first release profile. The FFI artifact (libcpex_ffi.a) is linked
# statically into host binaries, so its compiled size flows straight into
# those images. The default release profile leaves symbols + debug info in
# and does no cross-crate inlining/dead-code elimination; the settings below
# trade build time for a materially smaller archive.
#
# panic = "abort" is intentionally NOT set: cpex-ffi catches panics at its
# `#[no_mangle]` boundary with `catch_unwind`, and aborting would turn a
# recoverable policy panic into a host-process kill. Revisit only if that
# boundary is reworked to not rely on unwinding.
[profile.release]
opt-level = "z" # optimize for size
lto = true # cross-crate inlining + dead-code elimination
codegen-units = 1 # maximize optimization (one unit, no parallel-codegen bloat)
strip = true # drop symbols + debug info from the artifact
11 changes: 11 additions & 0 deletions crates/apl-pdp-cedar-direct/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ apl-core = { path = "../apl-core" }
# constructor form). Tracked separately if we ever need to support
# pre-4.x or post-5.x.
cedar-policy = "4"
# Stack-growth guard for the cedar evaluation path. cedar-policy-core aborts
# with "recursion limit reached" when `stacker::remaining_stack()` is below
# ~100 KiB; hosts that hand the FFI a small thread stack (musl defaults to
# 128 KiB) trip that on inputs glibc's 8 MiB stack handles fine. We use the
# same crate cedar does — 0.1.x dedups with cedar's transitive pin — to grow
# onto a fresh segment before calling in. See `evaluate`.
stacker = "0.1"
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
Expand All @@ -61,3 +68,7 @@ apl-cmf = { path = "../apl-cmf" }
apl-cpex = { path = "../apl-cpex" }
cpex-core = { path = "../cpex-core" }
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }
# Minimal executor for the small-stack regression test (tests/small_stack_eval.rs):
# drives the async `evaluate` without tokio's larger per-call stack footprint, so
# the 128 KiB test thread exercises the maybe_grow path, not the runtime.
futures = { workspace = true }
66 changes: 45 additions & 21 deletions crates/apl-pdp-cedar-direct/src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ use crate::entities::build as build_entities;
use crate::error::BuildError;
use crate::request::parse as parse_call;

/// Grow the cedar evaluation stack when the current thread has less than this
/// much headroom. cedar-policy-core's own guard is 100 KiB
/// (`REQUIRED_STACK_SPACE`); we grow at 10x that so the guard never fires
/// mid-descent. On glibc (8 MiB thread stacks) there is always more than this
/// available, so `maybe_grow` is a cheap no-op; on musl (128 KiB default) we
/// fall below it and grow onto a fresh segment.
const CEDAR_STACK_RED_ZONE: usize = 1024 * 1024;
/// Size of the fresh stack segment to run cedar on when we grow. Matches
/// glibc's default 8 MiB thread stack — the headroom cedar is validated
/// against. Allocated only on small-stack hosts, freed when evaluation returns.
const CEDAR_STACK_GROW_SIZE: usize = 8 * 1024 * 1024;

/// PdpResolver wrapping a bare `cedar-policy` engine. Constructed from
/// policy text / file / config block at startup; evaluates each call
/// against the loaded `PolicySet`.
Expand Down Expand Up @@ -207,31 +219,43 @@ impl PdpResolver for CedarDirectResolver {
args: resolved_args,
};

let parsed = parse_call(&resolved_call, bag, self.schema.as_deref())?;
let entities = build_entities(
bag,
parsed.resource_args,
self.schema.as_deref(),
self.entity_namespace.as_deref(),
)?;
// Everything below recurses through cedar (context/entity JSON parsing
// and policy evaluation), which self-aborts with "recursion limit
// reached" when the running thread's remaining stack drops under
// cedar's 100 KiB floor. The FFI host decides that thread's stack size,
// and musl's 128 KiB default trips the floor on inputs glibc handles
// fine. `maybe_grow` runs this block on a fresh, generously-sized stack
// segment when headroom is low (a no-op when there's already room, so
// glibc pays nothing), making cedar host-stack-agnostic. The block is
// fully synchronous — no `.await` — so it is safe to run inside the
// grown segment. See CEDAR_STACK_RED_ZONE / CEDAR_STACK_GROW_SIZE.
stacker::maybe_grow(CEDAR_STACK_RED_ZONE, CEDAR_STACK_GROW_SIZE, || {
let parsed = parse_call(&resolved_call, bag, self.schema.as_deref())?;
let entities = build_entities(
bag,
parsed.resource_args,
self.schema.as_deref(),
self.entity_namespace.as_deref(),
)?;

let principal_uid = build_principal_uid(bag, self.entity_namespace.as_deref())?;
let resource_uid = build_resource_uid(parsed.resource_args)?;
let principal_uid = build_principal_uid(bag, self.entity_namespace.as_deref())?;
let resource_uid = build_resource_uid(parsed.resource_args)?;

let request = cedar_policy::Request::new(
principal_uid,
parsed.action,
resource_uid,
parsed.context,
self.schema.as_deref(),
)
.map_err(|e| PdpError::Dispatch(format!("Cedar request validation failed: {}", e)))?;
let request = cedar_policy::Request::new(
principal_uid,
parsed.action,
resource_uid,
parsed.context,
self.schema.as_deref(),
)
.map_err(|e| PdpError::Dispatch(format!("Cedar request validation failed: {}", e)))?;

let response = self
.authorizer
.is_authorized(&request, &self.policies, &entities);
let response = self
.authorizer
.is_authorized(&request, &self.policies, &entities);

Ok(translate(&response, &self.policies))
Ok(translate(&response, &self.policies))
})
}
}

Expand Down
73 changes: 73 additions & 0 deletions crates/apl-pdp-cedar-direct/tests/small_stack_eval.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Location: ./crates/apl-pdp-cedar-direct/tests/small_stack_eval.rs
// Copyright 2025
// SPDX-License-Identifier: Apache-2.0
// Authors: Teryl Taylor
//
// Regression test for the musl small-stack / cedar "recursion limit" trap.
//
// cedar-policy-core guards evaluation against stack exhaustion by checking
// `stacker::remaining_stack()` against a 100 KiB floor (REQUIRED_STACK_SPACE).
// An FFI host chooses the thread stack the evaluation runs on: glibc defaults
// threads to 8 MiB (clears the floor), but musl defaults to 128 KiB, so once
// the call chain has descended into evaluation the floor trips and cedar
// returns "recursion limit reached" on inputs that decide fine on glibc.
//
// `CedarDirectResolver::evaluate` wraps the cedar work in `stacker::maybe_grow`,
// which runs it on a fresh, generously-sized segment when the current stack is
// low — making cedar host-stack-agnostic. This test pins that behavior by
// evaluating on a 128 KiB OS thread (musl's default):
//
// * with the guard -> grows onto a large segment -> Allow
// * without it -> cedar trips its floor -> Err("recursion limit reached")
//
// We use `futures::executor::block_on` rather than tokio: `evaluate` has no
// real await points (cedar is synchronous), and the lighter executor keeps the
// pre-`maybe_grow` footprint small so the 128 KiB proves the grow path, not the
// runtime.

use apl_core::attributes::AttributeBag;
use apl_core::evaluator::Decision;
use apl_core::step::{PdpCall, PdpDialect, PdpResolver};
use apl_pdp_cedar_direct::CedarDirectResolver;

/// musl's default thread stack size — below cedar's 100 KiB remaining-stack
/// floor once evaluation is underway.
const MUSL_DEFAULT_STACK: usize = 128 * 1024;

#[test]
fn evaluate_succeeds_on_musl_sized_thread_stack() {
let decision = std::thread::Builder::new()
.name("musl-stack-sim".into())
.stack_size(MUSL_DEFAULT_STACK)
.spawn(|| {
const POLICY: &str = r#"
@id("allow-all")
permit(principal, action, resource);
"#;
let resolver =
CedarDirectResolver::from_policy_text(POLICY).expect("policy parses");

let call = PdpCall {
dialect: PdpDialect::Cedar,
args: serde_yaml::from_str(
"action: 'Action::\"read\"'\nresource:\n type: Document\n id: doc-1\n",
)
.expect("call args parse"),
};
let mut bag = AttributeBag::new();
bag.set("subject.id", "alice");
bag.set("subject.type", "User");

futures::executor::block_on(resolver.evaluate(&call, &bag))
})
.expect("spawn 128 KiB thread")
.join()
.expect("evaluation thread must not overflow/panic")
.expect("cedar must evaluate on a musl-sized stack (maybe_grow guard)");

assert_eq!(
decision.decision,
Decision::Allow,
"an unconditional permit must Allow even on a 128 KiB thread stack",
);
}