From c94a535b0aa57c0218d5e2899480f264ffaf125a Mon Sep 17 00:00:00 2001 From: bordumb Date: Wed, 18 Mar 2026 22:11:38 +0000 Subject: [PATCH 1/3] feat: add ergonomic macros (#[context], #[main], #[requires] assertions) - Unseal Has

for context delegation, harden capsec-std with type-witnessed proofs, add CapRoot convenience methods, capsec::run(), #[capsec::main], #[capsec::context] with send variant, and upgrade #[requires] with on=param compile-time assertions. Update all examples, docs, and READMEs. --- CONTRIBUTING.md | 24 +- Cargo.lock | 28 + README.md | 37 +- crates/capsec-core/README.md | 12 +- crates/capsec-core/src/has.rs | 42 +- crates/capsec-core/src/root.rs | 89 +- crates/capsec-macro/Cargo.toml | 2 +- crates/capsec-macro/README.md | 59 +- crates/capsec-macro/src/lib.rs | 501 ++++++++++- crates/capsec-std/README.md | 2 +- crates/capsec-std/src/env.rs | 7 +- crates/capsec-std/src/fs.rs | 27 +- crates/capsec-std/src/net.rs | 7 +- crates/capsec-std/src/process.rs | 5 +- crates/capsec-tests/README.md | 13 +- .../has_forgery_capsec_std_env.rs | 13 - .../has_forgery_capsec_std_env.stderr | 37 - .../compile_fail/has_forgery_capsec_std_fs.rs | 16 - .../has_forgery_capsec_std_fs.stderr | 37 - .../compile_fail/has_forgery_god_mode.rs | 10 - .../compile_fail/has_forgery_god_mode.stderr | 37 - .../tests/compile_fail/has_forgery_loop.rs | 16 - .../compile_fail/has_forgery_loop.stderr | 37 - .../tests/compile_fail/has_forgery_panic.rs | 17 - .../compile_fail/has_forgery_panic.stderr | 37 - .../compile_fail/has_forgery_process_exit.rs | 16 - .../has_forgery_process_exit.stderr | 37 - .../sealed_has_no_external_impl.rs | 13 - .../sealed_has_no_external_impl.stderr | 37 - crates/capsec-tests/tests/type_system.rs | 147 +++- crates/capsec/Cargo.toml | 1 + crates/capsec/README.md | 31 +- crates/capsec/examples/async_context.rs | 42 + crates/capsec/examples/context_pattern.rs | 64 ++ crates/capsec/examples/context_struct.rs | 85 +- .../capsec/examples/incremental_migration.rs | 27 +- crates/capsec/examples/type_enforcement.rs | 29 +- crates/capsec/src/lib.rs | 12 +- .../tests/compile_fail/context_bad_field.rs | 9 + .../compile_fail/context_bad_field.stderr | 13 + .../compile_fail/context_duplicate_perm.rs | 10 + .../context_duplicate_perm.stderr | 13 + .../compile_fail/context_tuple_struct.rs | 7 + .../compile_fail/context_tuple_struct.stderr | 13 + .../tests/compile_fail/main_no_params.rs | 5 + .../tests/compile_fail/main_no_params.stderr | 19 + .../tests/compile_fail/main_wrong_type.rs | 5 + .../tests/compile_fail/main_wrong_type.stderr | 19 + .../compile_fail/requires_concrete_no_on.rs | 9 + .../requires_concrete_no_on.stderr | 14 + .../compile_fail/requires_on_wrong_type.rs | 9 + .../requires_on_wrong_type.stderr | 43 + crates/cargo-capsec/README.md | 2 +- ergonomics_spec.md | 825 ++++++++++++++++++ 54 files changed, 2080 insertions(+), 588 deletions(-) delete mode 100644 crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_env.rs delete mode 100644 crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_env.stderr delete mode 100644 crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_fs.rs delete mode 100644 crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_fs.stderr delete mode 100644 crates/capsec-tests/tests/compile_fail/has_forgery_god_mode.rs delete mode 100644 crates/capsec-tests/tests/compile_fail/has_forgery_god_mode.stderr delete mode 100644 crates/capsec-tests/tests/compile_fail/has_forgery_loop.rs delete mode 100644 crates/capsec-tests/tests/compile_fail/has_forgery_loop.stderr delete mode 100644 crates/capsec-tests/tests/compile_fail/has_forgery_panic.rs delete mode 100644 crates/capsec-tests/tests/compile_fail/has_forgery_panic.stderr delete mode 100644 crates/capsec-tests/tests/compile_fail/has_forgery_process_exit.rs delete mode 100644 crates/capsec-tests/tests/compile_fail/has_forgery_process_exit.stderr delete mode 100644 crates/capsec-tests/tests/compile_fail/sealed_has_no_external_impl.rs delete mode 100644 crates/capsec-tests/tests/compile_fail/sealed_has_no_external_impl.stderr create mode 100644 crates/capsec/examples/async_context.rs create mode 100644 crates/capsec/examples/context_pattern.rs create mode 100644 crates/capsec/tests/compile_fail/context_bad_field.rs create mode 100644 crates/capsec/tests/compile_fail/context_bad_field.stderr create mode 100644 crates/capsec/tests/compile_fail/context_duplicate_perm.rs create mode 100644 crates/capsec/tests/compile_fail/context_duplicate_perm.stderr create mode 100644 crates/capsec/tests/compile_fail/context_tuple_struct.rs create mode 100644 crates/capsec/tests/compile_fail/context_tuple_struct.stderr create mode 100644 crates/capsec/tests/compile_fail/main_no_params.rs create mode 100644 crates/capsec/tests/compile_fail/main_no_params.stderr create mode 100644 crates/capsec/tests/compile_fail/main_wrong_type.rs create mode 100644 crates/capsec/tests/compile_fail/main_wrong_type.stderr create mode 100644 crates/capsec/tests/compile_fail/requires_concrete_no_on.rs create mode 100644 crates/capsec/tests/compile_fail/requires_concrete_no_on.stderr create mode 100644 crates/capsec/tests/compile_fail/requires_on_wrong_type.rs create mode 100644 crates/capsec/tests/compile_fail/requires_on_wrong_type.stderr create mode 100644 ergonomics_spec.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d8b63c..eb98bcb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,8 +5,8 @@ ``` capsec/ ├── crates/ -│ ├── capsec-core/ # Zero-cost capability tokens, permission traits, sealed Has

-│ ├── capsec-macro/ # #[requires] and #[deny] proc macros +│ ├── capsec-core/ # Zero-cost capability tokens, permission traits, Has

+│ ├── capsec-macro/ # #[requires], #[deny], #[main], #[context] proc macros │ ├── capsec-std/ # Capability-gated wrappers for std::fs, std::net, etc. │ ├── capsec/ # Facade crate — re-exports everything, owns examples │ ├── cargo-capsec/ # Static audit CLI tool @@ -155,5 +155,23 @@ TRYBUILD=overwrite cargo test -p capsec --test compile_tests - **One concern per PR.** A new authority pattern, a bug fix, or a refactor — not all three. - **Include tests.** New authority patterns need integration tests. New type-system features need compile-fail tests. - **Run `cargo capsec audit`** against the repo itself before submitting — capsec dogfoods its own tool. -- **Keep the security model intact.** `Cap

` must remain unforgeable, `!Send`, and sealed. Any change that weakens these guarantees needs discussion in an issue first. +- **Keep the security model intact.** `Cap

` must remain unforgeable and `!Send`. `Permission` must remain sealed. `Cap::new()` must remain `pub(crate)`. Any change that weakens these guarantees needs discussion in an issue first. - **Update docs** if you change public API. The facade crate's `lib.rs` doc comments and crate READMEs should stay current. + +## Context pattern and macros + +capsec provides three ergonomic macros that work together: + +| Macro | Purpose | +|-------|---------| +| `#[capsec::context]` | Generates `Has

` impls on a struct, turning it into a capability context | +| `#[capsec::main]` | Injects `CapRoot` creation into a function entry point | +| `#[capsec::requires]` | Validates that a function's parameters satisfy declared permissions | + +When developing macros in `capsec-macro`: + +- All generated code uses fully qualified `capsec_core::*` paths (not `capsec::*`) +- Permission type validation must stay in sync with `capsec-core/src/permission.rs` +- The `resolve.rs` module maps shorthand paths (`fs::read`) to full types +- Add compile-fail tests in `capsec/tests/compile_fail/` for error cases +- Add runtime tests in `capsec-tests/tests/type_system.rs` for happy paths diff --git a/Cargo.lock b/Cargo.lock index 4976858..bc50b61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,6 +90,7 @@ dependencies = [ "capsec-core", "capsec-macro", "capsec-std", + "tokio", "trybuild", ] @@ -392,6 +393,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "prettyplease" version = "0.2.37" @@ -594,6 +601,27 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "1.0.7+spec-1.1.0" diff --git a/README.md b/README.md index 277d51b..2d3665a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/bordumb/capsec/actions/workflows/ci.yml/badge.svg)](https://github.com/bordumb/capsec/actions/workflows/ci.yml) [![crates.io](https://img.shields.io/crates/v/capsec.svg)](https://crates.io/crates/capsec) [![docs.rs](https://docs.rs/capsec/badge.svg)](https://docs.rs/capsec) -[![License: MIT OR Apache-2.0](https://img.shields.io/crates/l/capsec.svg)](LICENSE) +[![License: Apache-2.0](https://img.shields.io/crates/l/capsec.svg)](LICENSE) Capability-based security tooling for Rust. @@ -105,30 +105,33 @@ Functions declare their I/O requirements in the type signature. The compiler enf ```rust use capsec::prelude::*; -// This function CANNOT do I/O — it has no capability token. -// Adding std::fs::read() here would require a Cap parameter, -// which the compiler would demand. -pub fn process_csv(input: &[u8]) -> Vec> { - parse(input) +// Define a context with exactly the permissions your app needs. +// The macro generates Cap fields, constructor, and Has

impls. +#[capsec::context] +struct AppCtx { + fs: FsRead, + net: NetConnect, } -// This function declares it needs filesystem read access. -// The caller must provide proof via a Cap token. +// Leaf functions take &impl Has

— works with raw caps AND context structs. pub fn load_config(path: &str, cap: &impl Has) -> Result { capsec::fs::read_to_string(path, cap) } -// In main — the single point of authority: -fn main() { - let root = capsec::root(); - let fs_cap = root.grant::(); +// Intermediate functions take a single context reference — not N separate caps. +pub fn app_logic(ctx: &AppCtx) -> Result { + load_config("/etc/app/config.toml", ctx) // ctx satisfies Has +} - let config = load_config("/etc/app/config.toml", &fs_cap).unwrap(); - let result = process_csv(input); // no cap needed — pure computation +// #[capsec::main] injects the capability root automatically. +#[capsec::main] +fn main(root: CapRoot) { + let ctx = AppCtx::new(&root); + let config = app_logic(&ctx).unwrap(); } ``` -Every capability traces back to `root.grant()`. If a function uses capsec wrappers (like `capsec::fs::read_to_string`) without being given a `Cap`, the code doesn't compile. The audit tool catches code that bypasses capsec wrappers entirely — calling `std::fs` directly, using FFI, or hiding I/O behind re-exports. +Every capability traces back to `CapRoot`. If a function uses capsec wrappers (like `capsec::fs::read_to_string`) without being given a `Cap`, the code doesn't compile. The audit tool catches code that bypasses capsec wrappers entirely — calling `std::fs` directly, using FFI, or hiding I/O behind re-exports. ### What the compiler actually says @@ -200,6 +203,8 @@ These are real `rustc` errors — no custom error framework, no runtime panics. capsec protects against **cooperative safe Rust** — code that uses capsec wrappers cannot exceed its declared permissions, and the compiler enforces this at zero runtime cost. +The `Has

` trait is open for implementation — custom context structs can implement it to delegate capability access. Security is maintained because `Cap::new()` is `pub(crate)`: no external code can forge a `Cap

` in safe Rust. The `Permission` trait remains sealed — external crates cannot invent new permission types. + What capsec **does not** protect against: - **`unsafe` code** that forges capability tokens via `transmute`, `MaybeUninit`, or pointer tricks. The type system is sound only within safe Rust. (The adversarial test suite in `capsec-tests/tests/type_system.rs` documents these attacks and confirms they require `unsafe`.) @@ -226,4 +231,4 @@ For the full catalog of known evasion vectors and how each tool handles them, se ## License -MIT OR Apache-2.0 +Apache-2.0 diff --git a/crates/capsec-core/README.md b/crates/capsec-core/README.md index 092dc3d..e91abd9 100644 --- a/crates/capsec-core/README.md +++ b/crates/capsec-core/README.md @@ -8,12 +8,15 @@ This is the foundation crate of the [capsec](https://github.com/bordumb/capsec) - **`Permission`** — sealed marker trait for capability categories (`FsRead`, `NetConnect`, `Spawn`, etc.) - **`Cap

`** — zero-sized proof token that the holder has permission `P` -- **`Has

`** — trait bound for declaring capability requirements in function signatures -- **`CapRoot`** — singleton factory for granting capabilities +- **`SendCap

`** — thread-safe variant of `Cap

` (`Send + Sync`) +- **`Has

`** — trait bound for declaring capability requirements. Open for implementation — custom context structs can implement `Has

` to delegate capability access. +- **`CapRoot`** — singleton factory for granting capabilities, with convenience methods (`fs_read()`, `net_connect()`, etc.) - **`Attenuated`** — scope-restricted capabilities (`DirScope`, `HostScope`) All types are zero-sized at runtime. No overhead. +Security is maintained because `Cap::new()` is `pub(crate)` — no external code can forge a `Cap

` in safe Rust. The `Permission` trait remains sealed. + ## Example ```rust,ignore @@ -22,7 +25,10 @@ use capsec_core::permission::FsRead; use capsec_core::has::Has; let root = test_root(); + +// Turbofish or convenience method — both work: let cap = root.grant::(); +let cap = root.fs_read(); fn needs_fs(cap: &impl Has) { // can only be called with proof of FsRead permission @@ -33,4 +39,4 @@ needs_fs(&cap); ## License -MIT OR Apache-2.0 +Apache-2.0 diff --git a/crates/capsec-core/src/has.rs b/crates/capsec-core/src/has.rs index 82e17e4..018906e 100644 --- a/crates/capsec-core/src/has.rs +++ b/crates/capsec-core/src/has.rs @@ -32,13 +32,17 @@ //! `Cap` satisfies `Has` and `Has` because `FsAll` //! subsumes both. `Cap` satisfies `Has

` for every permission. -use crate::cap::Cap; +use crate::cap::{Cap, SendCap}; use crate::permission::*; /// Proof that a capability token includes permission `P`. /// -/// This trait is **sealed** — it cannot be implemented outside `capsec-core`. -/// Use [`CapRoot::grant()`](crate::root::CapRoot::grant) to obtain capability tokens. +/// This trait is open for implementation — custom context structs can implement +/// `Has

` to delegate capability access. Security is maintained because +/// `Cap::new()` is `pub(crate)`: no external code can forge a `Cap

` in safe Rust. +/// +/// Use [`CapRoot::grant()`](crate::root::CapRoot::grant) to obtain capability tokens, +/// or implement `Has

` on your own structs using the `#[capsec::context]` macro. /// /// # Example /// @@ -54,28 +58,11 @@ use crate::permission::*; /// let cap = root.grant::(); /// needs_fs(&cap); /// ``` -#[allow(private_bounds)] -pub trait Has: sealed::Sealed

{ +pub trait Has { /// Returns a new `Cap

` proving the permission is available. fn cap_ref(&self) -> Cap

; } -// Sealed supertrait for Has

— prevents external implementations. -// -// This is separate from the `sealed` module in `permission.rs`, which seals the -// Permission trait (controlling which types can be permissions). This module seals -// the Has trait (controlling which types can claim to hold a permission). - -mod sealed { - use crate::cap::Cap; - use crate::permission::Permission; - - pub trait Sealed {} - - // Direct: Cap

satisfies Has

for any permission P - impl Sealed

for Cap

{} -} - // Direct: Cap

implements Has

impl Has

for Cap

{ @@ -84,12 +71,19 @@ impl Has

for Cap

{ } } +// SendCap

delegates to Has

+ +impl Has

for SendCap

{ + fn cap_ref(&self) -> Cap

{ + self.as_cap() + } +} + // Subsumption: FsAll, NetAll macro_rules! impl_subsumes { ($super:ty => $($sub:ty),+) => { $( - impl sealed::Sealed<$sub> for Cap<$super> {} impl Has<$sub> for Cap<$super> { fn cap_ref(&self) -> Cap<$sub> { Cap::new() } } @@ -109,7 +103,6 @@ impl_subsumes!(NetAll => NetConnect, NetBind); macro_rules! impl_ambient { ($($perm:ty),+) => { $( - impl sealed::Sealed<$perm> for Cap {} impl Has<$perm> for Cap { fn cap_ref(&self) -> Cap<$perm> { Cap::new() } } @@ -137,7 +130,6 @@ macro_rules! impl_tuple_has_first { }; (@inner $a:ident; [$($b:ident),+]) => { $( - impl sealed::Sealed<$a> for Cap<($a, $b)> {} impl Has<$a> for Cap<($a, $b)> { fn cap_ref(&self) -> Cap<$a> { Cap::new() } } @@ -148,11 +140,9 @@ macro_rules! impl_tuple_has_first { macro_rules! impl_tuple_has_second { ($first:ident $(, $rest:ident)+) => { $( - impl sealed::Sealed<$first> for Cap<($rest, $first)> {} impl Has<$first> for Cap<($rest, $first)> { fn cap_ref(&self) -> Cap<$first> { Cap::new() } } - impl sealed::Sealed<$rest> for Cap<($first, $rest)> {} impl Has<$rest> for Cap<($first, $rest)> { fn cap_ref(&self) -> Cap<$rest> { Cap::new() } } diff --git a/crates/capsec-core/src/root.rs b/crates/capsec-core/src/root.rs index 291cbbe..a43f423 100644 --- a/crates/capsec-core/src/root.rs +++ b/crates/capsec-core/src/root.rs @@ -17,7 +17,7 @@ //! conflicts across parallel test threads. use crate::cap::Cap; -use crate::permission::Permission; +use crate::permission::*; use std::sync::atomic::{AtomicBool, Ordering}; static ROOT_CREATED: AtomicBool = AtomicBool::new(false); @@ -93,12 +93,62 @@ impl CapRoot { pub fn grant(&self) -> Cap

{ Cap::new() } + + /// Grants a `Cap` for filesystem read access. + pub fn fs_read(&self) -> Cap { + self.grant() + } + + /// Grants a `Cap` for filesystem write access. + pub fn fs_write(&self) -> Cap { + self.grant() + } + + /// Grants a `Cap` for full filesystem access. + pub fn fs_all(&self) -> Cap { + self.grant() + } + + /// Grants a `Cap` for outbound network connections. + pub fn net_connect(&self) -> Cap { + self.grant() + } + + /// Grants a `Cap` for binding network listeners. + pub fn net_bind(&self) -> Cap { + self.grant() + } + + /// Grants a `Cap` for full network access. + pub fn net_all(&self) -> Cap { + self.grant() + } + + /// Grants a `Cap` for reading environment variables. + pub fn env_read(&self) -> Cap { + self.grant() + } + + /// Grants a `Cap` for writing environment variables. + pub fn env_write(&self) -> Cap { + self.grant() + } + + /// Grants a `Cap` for subprocess execution. + pub fn spawn(&self) -> Cap { + self.grant() + } + + /// Grants a `Cap` with full ambient authority. + pub fn ambient(&self) -> Cap { + self.grant() + } } #[cfg(test)] mod tests { use super::*; - use crate::permission::FsRead; + use crate::has::Has; #[test] fn test_root_works() { @@ -119,4 +169,39 @@ mod tests { let cap = root.grant::(); assert_eq!(std::mem::size_of_val(&cap), 0); } + + #[test] + fn convenience_methods_return_correct_types() { + let root = test_root(); + fn check_fs_read(_: &impl Has) {} + fn check_fs_write(_: &impl Has) {} + fn check_fs_all(_: &impl Has) {} + fn check_net_connect(_: &impl Has) {} + fn check_net_bind(_: &impl Has) {} + fn check_net_all(_: &impl Has) {} + fn check_env_read(_: &impl Has) {} + fn check_env_write(_: &impl Has) {} + fn check_spawn(_: &impl Has) {} + fn check_ambient(_: &impl Has) {} + + check_fs_read(&root.fs_read()); + check_fs_write(&root.fs_write()); + check_fs_all(&root.fs_all()); + check_net_connect(&root.net_connect()); + check_net_bind(&root.net_bind()); + check_net_all(&root.net_all()); + check_env_read(&root.env_read()); + check_env_write(&root.env_write()); + check_spawn(&root.spawn()); + check_ambient(&root.ambient()); + } + + #[test] + fn convenience_equivalent_to_grant() { + let root = test_root(); + // Both produce ZSTs of the same type + let _a: Cap = root.fs_read(); + let _b: Cap = root.grant(); + assert_eq!(std::mem::size_of_val(&_a), std::mem::size_of_val(&_b)); + } } diff --git a/crates/capsec-macro/Cargo.toml b/crates/capsec-macro/Cargo.toml index f135f47..c478625 100644 --- a/crates/capsec-macro/Cargo.toml +++ b/crates/capsec-macro/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true -description = "Procedural macros for capsec: #[requires] and #[deny] capability annotations" +description = "Procedural macros for capsec: #[requires], #[deny], #[main], and #[context]" keywords = ["security", "capability", "proc-macro"] categories = ["development-tools::procedural-macro-helpers"] diff --git a/crates/capsec-macro/README.md b/crates/capsec-macro/README.md index 50273e4..a804f78 100644 --- a/crates/capsec-macro/README.md +++ b/crates/capsec-macro/README.md @@ -6,9 +6,55 @@ You probably want to depend on the `capsec` facade crate instead of using this d ## Macros +### `#[capsec::main]` + +Injects `CapRoot` creation into a function entry point. Removes the first `CapRoot` parameter and prepends `let root = capsec::root();` to the body. + +```rust,ignore +#[capsec::main] +fn main(root: CapRoot) { + let fs = root.fs_read(); + // ... +} +``` + +When combining with `#[tokio::main]`, place `#[capsec::main]` above: + +```rust,ignore +#[capsec::main] +#[tokio::main] +async fn main(root: CapRoot) { ... } +``` + +### `#[capsec::context]` + +Transforms a struct with permission-type fields into a capability context. Generates `Cap

` fields, a `new(root)` constructor, and `Has

` impls for each field. + +```rust,ignore +#[capsec::context] +struct AppCtx { + fs: FsRead, + net: NetConnect, +} + +// Generated: AppCtx::new(&root), impl Has for AppCtx, impl Has for AppCtx +``` + +For async/threaded code, use the `send` variant to generate `SendCap

` fields: + +```rust,ignore +#[capsec::context(send)] +struct AsyncCtx { + fs: FsRead, +} +// AsyncCtx is Send + Sync, can be wrapped in Arc +``` + ### `#[capsec::requires(...)]` -Declares a function's capability requirements. **This macro is documentation-only** — it does not enforce anything at compile time. Actual enforcement comes from the `Has

` trait bounds on the function's capability parameter. The macro makes the intent explicit for tooling and human readers. +Declares and validates a function's capability requirements. + +With `impl Has

` bounds, the compiler already enforces the trait bounds — the macro emits only a `#[doc]` attribute: ```rust,ignore #[capsec::requires(fs::read, net::connect)] @@ -17,6 +63,15 @@ fn sync_data(fs: &impl Has, net: &impl Has) -> Result<()> { } ``` +With concrete context types, use `on = param` to emit a compile-time assertion that the parameter type implements `Has

`: + +```rust,ignore +#[capsec::requires(fs::read, net::connect, on = ctx)] +fn sync_data(config: &Config, ctx: &AppCtx) -> Result<()> { + // ... +} +``` + ### `#[capsec::deny(...)]` Marks a function as capability-free. The `cargo capsec check` lint tool will flag any ambient authority call inside it. @@ -34,4 +89,4 @@ fn pure_transform(input: &[u8]) -> Vec { ## License -MIT OR Apache-2.0 +Apache-2.0 diff --git a/crates/capsec-macro/src/lib.rs b/crates/capsec-macro/src/lib.rs index 5158512..1b1e088 100644 --- a/crates/capsec-macro/src/lib.rs +++ b/crates/capsec-macro/src/lib.rs @@ -2,11 +2,12 @@ //! //! Procedural macros for the `capsec` capability-based security system. //! -//! Provides two attribute macros: +//! Provides attribute macros: //! -//! - [`requires`] — declares a function's capability requirements for tooling -//! and documentation. +//! - [`requires`] — declares and validates a function's capability requirements. //! - [`deny`] — marks a function as capability-free for the lint tool. +//! - [`main`] — injects `CapRoot` creation into a function entry point. +//! - [`context`] — generates `Has

` impls and constructor for a capability context struct. //! //! These macros are re-exported by the `capsec` facade crate. You don't need to //! depend on `capsec-macro` directly. @@ -14,27 +15,45 @@ mod resolve; use proc_macro::TokenStream; -use quote::quote; +use quote::{format_ident, quote}; use syn::punctuated::Punctuated; -use syn::{ItemFn, Meta, Token, parse_macro_input}; +use syn::{FnArg, ItemFn, ItemStruct, Meta, Pat, Token, Type, parse_macro_input}; + +/// The set of known permission type names (bare idents). +const KNOWN_PERMISSIONS: &[&str] = &[ + "FsRead", + "FsWrite", + "FsAll", + "NetConnect", + "NetBind", + "NetAll", + "EnvRead", + "EnvWrite", + "Spawn", + "Ambient", +]; /// Declares the capability requirements of a function. /// -/// **This macro is documentation-only.** It adds a `#[doc]` attribute for -/// tooling and human readers. It does **not** enforce anything at compile -/// time — no trait bound assertions are emitted. +/// When all parameters use `impl Has

` bounds, the compiler already enforces +/// the trait bounds and this macro emits only a `#[doc]` attribute. /// -/// Actual enforcement comes from the `Has

` trait bounds on the function's -/// capability parameter. If a function takes `cap: &impl Has`, the -/// compiler will reject callers that pass the wrong capability type regardless -/// of whether `#[requires]` is present. The macro exists to make the intent -/// explicit and machine-readable. +/// When concrete parameter types are used (e.g., context structs), use `on = param` +/// to identify the capability parameter. The macro emits a compile-time assertion +/// that the parameter type implements `Has

` for each declared permission. /// /// # Usage /// /// ```rust,ignore +/// // With impl bounds — no `on` needed /// #[capsec::requires(fs::read, net::connect)] -/// fn sync_data(cap: &impl Has + Has) -> Result<()> { +/// fn sync_data(cap: &(impl Has + Has)) -> Result<()> { +/// // ... +/// } +/// +/// // With concrete context type — use `on = param` +/// #[capsec::requires(fs::read, net::connect, on = ctx)] +/// fn sync_data(config: &Config, ctx: &AppCtx) -> Result<()> { /// // ... /// } /// ``` @@ -55,40 +74,155 @@ use syn::{ItemFn, Meta, Token, parse_macro_input}; /// | `all` | `Ambient` | `capsec_core::permission::Ambient` | #[proc_macro_attribute] pub fn requires(attr: TokenStream, item: TokenStream) -> TokenStream { - let capabilities = - parse_macro_input!(attr with Punctuated::::parse_terminated); + let attr2: proc_macro2::TokenStream = attr.into(); let func = parse_macro_input!(item as ItemFn); - let cap_names: Vec<_> = capabilities - .iter() - .map(|meta| match resolve::meta_to_permission_type(meta) { - Ok(tokens) => tokens, - Err(e) => e.into_compile_error(), - }) - .collect(); + match requires_inner(attr2, &func) { + Ok(tokens) => tokens.into(), + Err(e) => e.into_compile_error().into(), + } +} +fn requires_inner( + attr: proc_macro2::TokenStream, + func: &ItemFn, +) -> syn::Result { + let metas: Punctuated = + syn::parse::Parser::parse2(Punctuated::parse_terminated, attr)?; + + // Separate `on = param` from permission metas + let mut on_param: Option = None; + let mut perm_metas: Vec<&Meta> = Vec::new(); + + for meta in &metas { + if let Meta::NameValue(nv) = meta + && nv.path.is_ident("on") + { + if let syn::Expr::Path(ep) = &nv.value + && let Some(ident) = ep.path.get_ident() + { + on_param = Some(ident.clone()); + continue; + } + return Err(syn::Error::new_spanned(&nv.value, "expected an identifier")); + } + perm_metas.push(meta); + } + + // Resolve permission types + let mut cap_types = Vec::new(); + for meta in &perm_metas { + cap_types.push(resolve::meta_to_permission_type(meta)?); + } + + // Build doc string let doc_string = format!( "capsec::requires({})", - cap_names + cap_types .iter() .map(|c| quote!(#c).to_string()) .collect::>() .join(", ") ); + // Check if any parameter uses `impl` trait bounds + let has_impl_bounds = func.sig.inputs.iter().any(|arg| { + if let FnArg::Typed(pat_type) = arg { + contains_impl_trait(&pat_type.ty) + } else { + false + } + }); + + // Build assertion block if needed + let assertion = if let Some(ref param_name) = on_param { + // Find the parameter and extract its type + let param_type = find_param_type(&func.sig, param_name)?; + let inner_type = unwrap_references(¶m_type); + + let assert_fns: Vec<_> = cap_types + .iter() + .enumerate() + .map(|(i, perm_ty)| { + let fn_name = format_ident!("_assert_has_{}", i); + quote! { + fn #fn_name>() {} + } + }) + .collect(); + + let assert_calls: Vec<_> = (0..cap_types.len()) + .map(|i| { + let fn_name = format_ident!("_assert_has_{}", i); + quote! { #fn_name::<#inner_type>(); } + }) + .collect(); + + Some(quote! { + const _: () = { + #(#assert_fns)* + fn _check() { + #(#assert_calls)* + } + }; + }) + } else if !has_impl_bounds && !func.sig.inputs.is_empty() && !cap_types.is_empty() { + // Concrete types present but no `on` keyword + return Err(syn::Error::new_spanned( + &func.sig, + "#[capsec::requires] on a function with concrete parameter types requires \ + `on = ` to identify the capability parameter.\n\ + Example: #[capsec::requires(fs::read, on = ctx)]", + )); + } else { + None + }; + let func_vis = &func.vis; let func_sig = &func.sig; let func_block = &func.block; let func_attrs = &func.attrs; - let expanded = quote! { + Ok(quote! { #(#func_attrs)* #[doc = #doc_string] - #func_vis #func_sig + #func_vis #func_sig { + #assertion #func_block - }; + } + }) +} - expanded.into() +fn contains_impl_trait(ty: &Type) -> bool { + match ty { + Type::ImplTrait(_) => true, + Type::Reference(r) => contains_impl_trait(&r.elem), + Type::Paren(p) => contains_impl_trait(&p.elem), + _ => false, + } +} + +fn find_param_type(sig: &syn::Signature, name: &syn::Ident) -> syn::Result { + for arg in &sig.inputs { + if let FnArg::Typed(pat_type) = arg + && let Pat::Ident(pi) = &*pat_type.pat + && pi.ident == *name + { + return Ok((*pat_type.ty).clone()); + } + } + Err(syn::Error::new_spanned( + name, + format!("parameter '{}' not found in function signature", name), + )) +} + +fn unwrap_references(ty: &Type) -> &Type { + match ty { + Type::Reference(r) => unwrap_references(&r.elem), + Type::Paren(p) => unwrap_references(&p.elem), + _ => ty, + } } /// Marks a function as capability-free. @@ -157,3 +291,314 @@ pub fn deny(attr: TokenStream, item: TokenStream) -> TokenStream { expanded.into() } + +/// Injects `CapRoot` creation into a function entry point. +/// +/// Removes the first parameter (which must be typed as `CapRoot`) and prepends +/// `let {param_name} = capsec::root();` to the function body. +/// +/// # Usage +/// +/// ```rust,ignore +/// #[capsec::main] +/// fn main(root: CapRoot) { +/// let fs = root.fs_read(); +/// // ... +/// } +/// ``` +/// +/// # With `#[tokio::main]` +/// +/// Place `#[capsec::main]` above `#[tokio::main]`: +/// +/// ```rust,ignore +/// #[capsec::main] +/// #[tokio::main] +/// async fn main(root: CapRoot) { ... } +/// ``` +#[proc_macro_attribute] +pub fn main(_attr: TokenStream, item: TokenStream) -> TokenStream { + let func = parse_macro_input!(item as ItemFn); + + match main_inner(&func) { + Ok(tokens) => tokens.into(), + Err(e) => e.into_compile_error().into(), + } +} + +fn main_inner(func: &ItemFn) -> syn::Result { + if func.sig.inputs.is_empty() { + if func.sig.asyncness.is_some() { + return Err(syn::Error::new_spanned( + &func.sig, + "#[capsec::main] found no CapRoot parameter. If combining with #[tokio::main], \ + place #[capsec::main] above #[tokio::main]:\n\n \ + #[capsec::main]\n \ + #[tokio::main]\n \ + async fn main(root: CapRoot) { ... }", + )); + } + return Err(syn::Error::new_spanned( + &func.sig, + "#[capsec::main] expected first parameter of type CapRoot", + )); + } + + // Extract first parameter + let first_arg = &func.sig.inputs[0]; + let (param_name, param_type) = match first_arg { + FnArg::Typed(pat_type) => { + let name = if let Pat::Ident(pi) = &*pat_type.pat { + pi.ident.clone() + } else { + return Err(syn::Error::new_spanned( + &pat_type.pat, + "#[capsec::main] expected a simple identifier for the CapRoot parameter", + )); + }; + (name, &*pat_type.ty) + } + FnArg::Receiver(r) => { + return Err(syn::Error::new_spanned( + r, + "#[capsec::main] cannot be used on methods with self", + )); + } + }; + + // Validate type is CapRoot + let type_str = quote!(#param_type).to_string().replace(' ', ""); + if type_str != "CapRoot" && type_str != "capsec::CapRoot" { + return Err(syn::Error::new_spanned( + param_type, + "first parameter must be CapRoot", + )); + } + + // Build new signature without the first parameter + let remaining_params: Vec<_> = func.sig.inputs.iter().skip(1).collect(); + let func_attrs = &func.attrs; + let func_vis = &func.vis; + let func_name = &func.sig.ident; + let func_generics = &func.sig.generics; + let func_output = &func.sig.output; + let func_asyncness = &func.sig.asyncness; + let func_block = &func.block; + + Ok(quote! { + #(#func_attrs)* + #func_vis #func_asyncness fn #func_name #func_generics(#(#remaining_params),*) #func_output { + let #param_name = capsec::root(); + #func_block + } + }) +} + +/// Transforms a struct with permission-type fields into a capability context. +/// +/// Generates: +/// - Field types rewritten from `PermType` to `Cap` (or `SendCap`) +/// - A `new(root: &CapRoot) -> Self` constructor +/// - `impl Has

` for each field's permission type +/// +/// # Usage +/// +/// ```rust,ignore +/// #[capsec::context] +/// struct AppCtx { +/// fs: FsRead, +/// net: NetConnect, +/// } +/// +/// // Send variant for async/threaded code: +/// #[capsec::context(send)] +/// struct AsyncCtx { +/// fs: FsRead, +/// net: NetConnect, +/// } +/// ``` +#[proc_macro_attribute] +pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream { + let attr2: proc_macro2::TokenStream = attr.into(); + let input = parse_macro_input!(item as ItemStruct); + + match context_inner(attr2, &input) { + Ok(tokens) => tokens.into(), + Err(e) => e.into_compile_error().into(), + } +} + +fn context_inner( + attr: proc_macro2::TokenStream, + input: &ItemStruct, +) -> syn::Result { + // Parse `send` flag + let attr_str = attr.to_string(); + let is_send = match attr_str.trim() { + "" => false, + "send" => true, + other => { + return Err(syn::Error::new_spanned( + &attr, + format!("unexpected attribute '{}', expected empty or 'send'", other), + )); + } + }; + + // Reject generics + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "#[capsec::context] does not support generic structs", + )); + } + + // Get named fields + let fields = match &input.fields { + syn::Fields::Named(f) => f, + _ => { + return Err(syn::Error::new_spanned( + input, + "#[capsec::context] requires a struct with named fields", + )); + } + }; + + // Validate fields and collect permission info + let mut field_infos: Vec<(syn::Ident, syn::Ident)> = Vec::new(); // (field_name, perm_ident) + let mut seen_perms: std::collections::HashSet = std::collections::HashSet::new(); + + for field in &fields.named { + let field_name = field.ident.as_ref().unwrap().clone(); + let ty = &field.ty; + + // Check for tuple types + if let Type::Tuple(_) = ty { + return Err(syn::Error::new_spanned( + ty, + "tuple permission types are not supported in context structs — use separate fields instead", + )); + } + + // Extract type ident (last segment of path) + let perm_ident = match ty { + Type::Path(tp) => { + if let Some(seg) = tp.path.segments.last() { + seg.ident.clone() + } else { + return Err(syn::Error::new_spanned( + ty, + format!( + "field '{}' has type '{}', which is not a capsec permission type. \ + Expected one of: {}", + field_name, + quote!(#ty), + KNOWN_PERMISSIONS.join(", ") + ), + )); + } + } + _ => { + return Err(syn::Error::new_spanned( + ty, + format!( + "field '{}' has type '{}', which is not a capsec permission type. \ + Expected one of: {}", + field_name, + quote!(#ty), + KNOWN_PERMISSIONS.join(", ") + ), + )); + } + }; + + let perm_str = perm_ident.to_string(); + + // Validate against known permissions + if !KNOWN_PERMISSIONS.contains(&perm_str.as_str()) { + return Err(syn::Error::new_spanned( + ty, + format!( + "field '{}' has type '{}', which is not a capsec permission type. \ + Expected one of: {}", + field_name, + perm_str, + KNOWN_PERMISSIONS.join(", ") + ), + )); + } + + // Check for duplicates + if !seen_perms.insert(perm_str.clone()) { + return Err(syn::Error::new_spanned( + ty, + format!( + "duplicate permission type '{}' — each permission can only appear once in a context struct", + perm_str + ), + )); + } + + field_infos.push((field_name, perm_ident)); + } + + let struct_name = &input.ident; + let struct_vis = &input.vis; + let struct_attrs = &input.attrs; + + // Generate struct fields with rewritten types + let struct_fields: Vec<_> = field_infos + .iter() + .map(|(name, perm)| { + if is_send { + quote! { #name: capsec_core::cap::SendCap } + } else { + quote! { #name: capsec_core::cap::Cap } + } + }) + .collect(); + + // Generate constructor fields + let constructor_fields: Vec<_> = field_infos + .iter() + .map(|(name, perm)| { + if is_send { + quote! { #name: root.grant::().make_send() } + } else { + quote! { #name: root.grant::() } + } + }) + .collect(); + + // Generate Has

impls + let has_impls: Vec<_> = field_infos + .iter() + .map(|(name, perm)| { + quote! { + impl capsec_core::has::Has for #struct_name { + fn cap_ref(&self) -> capsec_core::cap::Cap { + self.#name.cap_ref() + } + } + } + }) + .collect(); + + Ok(quote! { + #(#struct_attrs)* + #struct_vis struct #struct_name { + #(#struct_fields,)* + } + + impl #struct_name { + /// Creates a new context by granting all capabilities from the root. + pub fn new(root: &capsec_core::root::CapRoot) -> Self { + Self { + #(#constructor_fields,)* + } + } + } + + #(#has_impls)* + }) +} diff --git a/crates/capsec-std/README.md b/crates/capsec-std/README.md index f3fbbc3..68aca91 100644 --- a/crates/capsec-std/README.md +++ b/crates/capsec-std/README.md @@ -32,4 +32,4 @@ let data = capsec_std::fs::read("/tmp/data.bin", &cap).unwrap(); ## License -MIT OR Apache-2.0 +Apache-2.0 diff --git a/crates/capsec-std/src/env.rs b/crates/capsec-std/src/env.rs index e3e767e..e64d0ed 100644 --- a/crates/capsec-std/src/env.rs +++ b/crates/capsec-std/src/env.rs @@ -2,20 +2,21 @@ //! //! Drop-in replacements for `std::env` functions that require a capability token. +use capsec_core::cap::Cap; use capsec_core::has::Has; use capsec_core::permission::{EnvRead, EnvWrite}; /// Reads an environment variable. /// Requires [`EnvRead`] permission. pub fn var(key: &str, cap: &impl Has) -> Result { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); std::env::var(key) } /// Returns an iterator of all environment variables. /// Requires [`EnvRead`] permission. pub fn vars(cap: &impl Has) -> std::env::Vars { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); std::env::vars() } @@ -39,7 +40,7 @@ pub fn set_var( value: impl AsRef, cap: &impl Has, ) { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); unsafe { std::env::set_var(key, value); } diff --git a/crates/capsec-std/src/fs.rs b/crates/capsec-std/src/fs.rs index 44789a8..b3b5889 100644 --- a/crates/capsec-std/src/fs.rs +++ b/crates/capsec-std/src/fs.rs @@ -14,6 +14,7 @@ //! let data = capsec_std::fs::read("/tmp/data.bin", &cap).unwrap(); //! ``` +use capsec_core::cap::Cap; use capsec_core::error::CapSecError; use capsec_core::has::Has; use capsec_core::permission::{FsRead, FsWrite}; @@ -22,7 +23,7 @@ use std::path::Path; /// Reads the entire contents of a file into a byte vector. /// Requires [`FsRead`] permission. pub fn read(path: impl AsRef, cap: &impl Has) -> Result, CapSecError> { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(std::fs::read(path)?) } @@ -32,7 +33,7 @@ pub fn read_to_string( path: impl AsRef, cap: &impl Has, ) -> Result { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(std::fs::read_to_string(path)?) } @@ -42,7 +43,7 @@ pub fn read_dir( path: impl AsRef, cap: &impl Has, ) -> Result { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(std::fs::read_dir(path)?) } @@ -52,7 +53,7 @@ pub fn metadata( path: impl AsRef, cap: &impl Has, ) -> Result { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(std::fs::metadata(path)?) } @@ -63,28 +64,28 @@ pub fn write( contents: impl AsRef<[u8]>, cap: &impl Has, ) -> Result<(), CapSecError> { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(std::fs::write(path, contents)?) } /// Creates all directories in the given path if they don't exist. /// Requires [`FsWrite`] permission. pub fn create_dir_all(path: impl AsRef, cap: &impl Has) -> Result<(), CapSecError> { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(std::fs::create_dir_all(path)?) } /// Deletes a file. /// Requires [`FsWrite`] permission. pub fn remove_file(path: impl AsRef, cap: &impl Has) -> Result<(), CapSecError> { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(std::fs::remove_file(path)?) } /// Recursively deletes a directory and all its contents. /// Requires [`FsWrite`] permission. pub fn remove_dir_all(path: impl AsRef, cap: &impl Has) -> Result<(), CapSecError> { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(std::fs::remove_dir_all(path)?) } @@ -95,7 +96,7 @@ pub fn rename( to: impl AsRef, cap: &impl Has, ) -> Result<(), CapSecError> { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(std::fs::rename(from, to)?) } @@ -107,15 +108,15 @@ pub fn copy( read_cap: &impl Has, write_cap: &impl Has, ) -> Result { - let _ = read_cap.cap_ref(); - let _ = write_cap.cap_ref(); + let _read_proof: Cap = read_cap.cap_ref(); + let _write_proof: Cap = write_cap.cap_ref(); Ok(std::fs::copy(from, to)?) } /// Opens a file for reading. Returns a `std::fs::File`. /// Requires [`FsRead`] permission. pub fn open(path: impl AsRef, cap: &impl Has) -> Result { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(std::fs::File::open(path)?) } @@ -125,6 +126,6 @@ pub fn create( path: impl AsRef, cap: &impl Has, ) -> Result { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(std::fs::File::create(path)?) } diff --git a/crates/capsec-std/src/net.rs b/crates/capsec-std/src/net.rs index 321e7e7..4df54b7 100644 --- a/crates/capsec-std/src/net.rs +++ b/crates/capsec-std/src/net.rs @@ -2,6 +2,7 @@ //! //! Drop-in replacements for `std::net` functions that require a capability token. +use capsec_core::cap::Cap; use capsec_core::error::CapSecError; use capsec_core::has::Has; use capsec_core::permission::{NetBind, NetConnect}; @@ -13,7 +14,7 @@ pub fn tcp_connect( addr: impl ToSocketAddrs, cap: &impl Has, ) -> Result { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(TcpStream::connect(addr)?) } @@ -23,7 +24,7 @@ pub fn tcp_bind( addr: impl ToSocketAddrs, cap: &impl Has, ) -> Result { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(TcpListener::bind(addr)?) } @@ -33,6 +34,6 @@ pub fn udp_bind( addr: impl ToSocketAddrs, cap: &impl Has, ) -> Result { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(UdpSocket::bind(addr)?) } diff --git a/crates/capsec-std/src/process.rs b/crates/capsec-std/src/process.rs index 04eabdf..cab14ed 100644 --- a/crates/capsec-std/src/process.rs +++ b/crates/capsec-std/src/process.rs @@ -2,6 +2,7 @@ //! //! Drop-in replacements for `std::process` functions that require a capability token. +use capsec_core::cap::Cap; use capsec_core::error::CapSecError; use capsec_core::has::Has; use capsec_core::permission::Spawn; @@ -12,13 +13,13 @@ use std::process::{Command, Output}; /// /// Returns a `std::process::Command` that can be further configured before execution. pub fn command(program: &str, cap: &impl Has) -> Command { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Command::new(program) } /// Runs a program with arguments and returns its output. /// Requires [`Spawn`] permission. pub fn run(program: &str, args: &[&str], cap: &impl Has) -> Result { - let _ = cap.cap_ref(); + let _proof: Cap = cap.cap_ref(); Ok(Command::new(program).args(args).output()?) } diff --git a/crates/capsec-tests/README.md b/crates/capsec-tests/README.md index f5001d0..57f0372 100644 --- a/crates/capsec-tests/README.md +++ b/crates/capsec-tests/README.md @@ -40,16 +40,19 @@ docs/ findings.md # Full adversarial review with severity ratings ``` -### type_system.rs (20 tests) +### type_system.rs (23 tests) -- **Has\ forgery** — safe-code capability forgery via `panic!()` divergence (the critical finding) -- **capsec-std end-to-end exploits** — forged caps actually work with `fs::read`, `env::var` +- **Context delegation** — user-defined structs implementing `Has

` via `Cap

` fields +- **SendCap satisfies Has** — `SendCap

` implements `Has

` directly +- **Context macro** — `#[capsec::context]` structs satisfy `Has

`, work with capsec-std +- **Send context** — `#[capsec::context(send)]` produces `Send + Sync` structs +- **Requires validation** — `#[capsec::requires(perm, on = param)]` compiles with valid context - **unsafe forgery** — `transmute`, `MaybeUninit`, `ptr::read` (expected, documented) - **SendCap cross-thread** — roundtrip through `make_send()` / `as_cap()`, no escalation - **Attenuated move semantics** — `.attenuate()` consumes the original cap - **Negative controls** — clone, subsumption, cross-category all correctly blocked -### audit_evasion.rs (28 tests) +### audit_evasion.rs (29 tests) Confirmed evasions: function pointers, `include!()`, inline assembly, module re-exports, dependency re-exports, libc/nix calls. @@ -57,7 +60,7 @@ Fixed evasions: glob imports (`use std::fs::*; read(...)` now detected). Detection positive controls: `std::fs`, `File::open`, `TcpStream::connect`, `Command::new`, `env::var`, `extern` blocks, tokio, reqwest, aliased imports, closures, cfg-gated code. -### scope_escapes.rs (11 tests) +### scope_escapes.rs (13 tests) DirScope: `../` traversal, absolute escape, non-existent paths — all blocked. HostScope: prefix collision (`api.example.com.evil.com`) — confirmed bug. diff --git a/crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_env.rs b/crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_env.rs deleted file mode 100644 index d4245b4..0000000 --- a/crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_env.rs +++ /dev/null @@ -1,13 +0,0 @@ -/// End-to-end capsec-std env forgery is now blocked by Has

being sealed. -use capsec::prelude::*; - -struct EnvForgery; - -impl Has for EnvForgery { - fn cap_ref(&self) -> Cap { panic!() } -} - -fn main() { - let forge = EnvForgery; - let _ = capsec::env::var("PATH", &forge); -} diff --git a/crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_env.stderr b/crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_env.stderr deleted file mode 100644 index 73c9e50..0000000 --- a/crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_env.stderr +++ /dev/null @@ -1,37 +0,0 @@ -error[E0277]: the trait bound `EnvForgery: capsec_core::has::sealed::Sealed` is not satisfied - --> tests/compile_fail/has_forgery_capsec_std_env.rs:6:23 - | -6 | impl Has for EnvForgery { - | ^^^^^^^^^^ unsatisfied trait bound - | -help: the trait `capsec_core::has::sealed::Sealed` is not implemented for `EnvForgery` - --> tests/compile_fail/has_forgery_capsec_std_env.rs:4:1 - | -4 | struct EnvForgery; - | ^^^^^^^^^^^^^^^^^ - = help: the following other types implement trait `capsec_core::has::sealed::Sealed

`: - `capsec::Cap<(Ambient, Ambient)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvWrite)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvWrite)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsAll)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsAll)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsRead)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsRead)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsWrite)>` implements `capsec_core::has::sealed::Sealed` - and $N others -note: required by a bound in `capsec::Has` - --> $WORKSPACE/crates/capsec-core/src/has.rs - | - | pub trait Has: sealed::Sealed

{ - | ^^^^^^^^^^^^^^^^^ required by this bound in `Has` - = note: `Has` is a "sealed trait", because to implement it you also need to implement `capsec_core::has::sealed::Sealed`, which is not accessible; this is usually done to force you to use one of the provided types that already implement it - = help: the following types implement the trait: - capsec::Cap

- capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - and $N others diff --git a/crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_fs.rs b/crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_fs.rs deleted file mode 100644 index 12bcf6c..0000000 --- a/crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_fs.rs +++ /dev/null @@ -1,16 +0,0 @@ -/// End-to-end capsec-std forgery is now blocked by Has

being sealed. -/// Previously, a forged Has impl could read files via capsec_std::fs::read. -use capsec::prelude::*; - -struct Forgery; - -impl Has for Forgery { - fn cap_ref(&self) -> Cap { - panic!("never called — capsec-std used to ignore the cap parameter") - } -} - -fn main() { - let forge = Forgery; - let _ = capsec::fs::read("/dev/null", &forge); -} diff --git a/crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_fs.stderr b/crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_fs.stderr deleted file mode 100644 index 50df917..0000000 --- a/crates/capsec-tests/tests/compile_fail/has_forgery_capsec_std_fs.stderr +++ /dev/null @@ -1,37 +0,0 @@ -error[E0277]: the trait bound `Forgery: capsec_core::has::sealed::Sealed` is not satisfied - --> tests/compile_fail/has_forgery_capsec_std_fs.rs:7:22 - | -7 | impl Has for Forgery { - | ^^^^^^^ unsatisfied trait bound - | -help: the trait `capsec_core::has::sealed::Sealed` is not implemented for `Forgery` - --> tests/compile_fail/has_forgery_capsec_std_fs.rs:5:1 - | -5 | struct Forgery; - | ^^^^^^^^^^^^^^ - = help: the following other types implement trait `capsec_core::has::sealed::Sealed

`: - `capsec::Cap<(Ambient, Ambient)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvRead)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvRead)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvWrite)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvWrite)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsAll)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsAll)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsWrite)>` implements `capsec_core::has::sealed::Sealed` - and $N others -note: required by a bound in `capsec::Has` - --> $WORKSPACE/crates/capsec-core/src/has.rs - | - | pub trait Has: sealed::Sealed

{ - | ^^^^^^^^^^^^^^^^^ required by this bound in `Has` - = note: `Has` is a "sealed trait", because to implement it you also need to implement `capsec_core::has::sealed::Sealed`, which is not accessible; this is usually done to force you to use one of the provided types that already implement it - = help: the following types implement the trait: - capsec::Cap

- capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - and $N others diff --git a/crates/capsec-tests/tests/compile_fail/has_forgery_god_mode.rs b/crates/capsec-tests/tests/compile_fail/has_forgery_god_mode.rs deleted file mode 100644 index 0b48808..0000000 --- a/crates/capsec-tests/tests/compile_fail/has_forgery_god_mode.rs +++ /dev/null @@ -1,10 +0,0 @@ -/// God-mode forgery (claiming ALL permissions) is now blocked by Has

being sealed. -use capsec::prelude::*; - -struct GodForgery; - -impl Has for GodForgery { - fn cap_ref(&self) -> Cap { panic!() } -} - -fn main() {} diff --git a/crates/capsec-tests/tests/compile_fail/has_forgery_god_mode.stderr b/crates/capsec-tests/tests/compile_fail/has_forgery_god_mode.stderr deleted file mode 100644 index b475460..0000000 --- a/crates/capsec-tests/tests/compile_fail/has_forgery_god_mode.stderr +++ /dev/null @@ -1,37 +0,0 @@ -error[E0277]: the trait bound `GodForgery: capsec_core::has::sealed::Sealed` is not satisfied - --> tests/compile_fail/has_forgery_god_mode.rs:6:22 - | -6 | impl Has for GodForgery { - | ^^^^^^^^^^ unsatisfied trait bound - | -help: the trait `capsec_core::has::sealed::Sealed` is not implemented for `GodForgery` - --> tests/compile_fail/has_forgery_god_mode.rs:4:1 - | -4 | struct GodForgery; - | ^^^^^^^^^^^^^^^^^ - = help: the following other types implement trait `capsec_core::has::sealed::Sealed

`: - `capsec::Cap<(Ambient, Ambient)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvRead)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvRead)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvWrite)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvWrite)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsAll)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsAll)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsWrite)>` implements `capsec_core::has::sealed::Sealed` - and $N others -note: required by a bound in `capsec::Has` - --> $WORKSPACE/crates/capsec-core/src/has.rs - | - | pub trait Has: sealed::Sealed

{ - | ^^^^^^^^^^^^^^^^^ required by this bound in `Has` - = note: `Has` is a "sealed trait", because to implement it you also need to implement `capsec_core::has::sealed::Sealed`, which is not accessible; this is usually done to force you to use one of the provided types that already implement it - = help: the following types implement the trait: - capsec::Cap

- capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - and $N others diff --git a/crates/capsec-tests/tests/compile_fail/has_forgery_loop.rs b/crates/capsec-tests/tests/compile_fail/has_forgery_loop.rs deleted file mode 100644 index a0cc39f..0000000 --- a/crates/capsec-tests/tests/compile_fail/has_forgery_loop.rs +++ /dev/null @@ -1,16 +0,0 @@ -/// Forgery via loop {} divergence is now blocked by Has

being sealed. -use capsec::prelude::*; - -struct LoopForgery; - -impl Has for LoopForgery { - fn cap_ref(&self) -> Cap { - loop {} - } -} - -fn main() { - let forge = LoopForgery; - fn needs_fs_read(_cap: &impl Has) {} - needs_fs_read(&forge); -} diff --git a/crates/capsec-tests/tests/compile_fail/has_forgery_loop.stderr b/crates/capsec-tests/tests/compile_fail/has_forgery_loop.stderr deleted file mode 100644 index 04e74b1..0000000 --- a/crates/capsec-tests/tests/compile_fail/has_forgery_loop.stderr +++ /dev/null @@ -1,37 +0,0 @@ -error[E0277]: the trait bound `LoopForgery: capsec_core::has::sealed::Sealed` is not satisfied - --> tests/compile_fail/has_forgery_loop.rs:6:22 - | -6 | impl Has for LoopForgery { - | ^^^^^^^^^^^ unsatisfied trait bound - | -help: the trait `capsec_core::has::sealed::Sealed` is not implemented for `LoopForgery` - --> tests/compile_fail/has_forgery_loop.rs:4:1 - | -4 | struct LoopForgery; - | ^^^^^^^^^^^^^^^^^^ - = help: the following other types implement trait `capsec_core::has::sealed::Sealed

`: - `capsec::Cap<(Ambient, Ambient)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvRead)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvRead)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvWrite)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvWrite)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsAll)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsAll)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsWrite)>` implements `capsec_core::has::sealed::Sealed` - and $N others -note: required by a bound in `capsec::Has` - --> $WORKSPACE/crates/capsec-core/src/has.rs - | - | pub trait Has: sealed::Sealed

{ - | ^^^^^^^^^^^^^^^^^ required by this bound in `Has` - = note: `Has` is a "sealed trait", because to implement it you also need to implement `capsec_core::has::sealed::Sealed`, which is not accessible; this is usually done to force you to use one of the provided types that already implement it - = help: the following types implement the trait: - capsec::Cap

- capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - and $N others diff --git a/crates/capsec-tests/tests/compile_fail/has_forgery_panic.rs b/crates/capsec-tests/tests/compile_fail/has_forgery_panic.rs deleted file mode 100644 index f6f853e..0000000 --- a/crates/capsec-tests/tests/compile_fail/has_forgery_panic.rs +++ /dev/null @@ -1,17 +0,0 @@ -/// Forgery via panic!() divergence is now blocked by Has

being sealed. -/// Previously this compiled because panic!() diverges, satisfying any return type. -use capsec::prelude::*; - -struct Forgery; - -impl Has for Forgery { - fn cap_ref(&self) -> Cap { - panic!("This should not compile") - } -} - -fn main() { - let forge = Forgery; - fn needs_fs_read(_cap: &impl Has) {} - needs_fs_read(&forge); -} diff --git a/crates/capsec-tests/tests/compile_fail/has_forgery_panic.stderr b/crates/capsec-tests/tests/compile_fail/has_forgery_panic.stderr deleted file mode 100644 index 16b04c8..0000000 --- a/crates/capsec-tests/tests/compile_fail/has_forgery_panic.stderr +++ /dev/null @@ -1,37 +0,0 @@ -error[E0277]: the trait bound `Forgery: capsec_core::has::sealed::Sealed` is not satisfied - --> tests/compile_fail/has_forgery_panic.rs:7:22 - | -7 | impl Has for Forgery { - | ^^^^^^^ unsatisfied trait bound - | -help: the trait `capsec_core::has::sealed::Sealed` is not implemented for `Forgery` - --> tests/compile_fail/has_forgery_panic.rs:5:1 - | -5 | struct Forgery; - | ^^^^^^^^^^^^^^ - = help: the following other types implement trait `capsec_core::has::sealed::Sealed

`: - `capsec::Cap<(Ambient, Ambient)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvRead)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvRead)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvWrite)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvWrite)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsAll)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsAll)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsWrite)>` implements `capsec_core::has::sealed::Sealed` - and $N others -note: required by a bound in `capsec::Has` - --> $WORKSPACE/crates/capsec-core/src/has.rs - | - | pub trait Has: sealed::Sealed

{ - | ^^^^^^^^^^^^^^^^^ required by this bound in `Has` - = note: `Has` is a "sealed trait", because to implement it you also need to implement `capsec_core::has::sealed::Sealed`, which is not accessible; this is usually done to force you to use one of the provided types that already implement it - = help: the following types implement the trait: - capsec::Cap

- capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - and $N others diff --git a/crates/capsec-tests/tests/compile_fail/has_forgery_process_exit.rs b/crates/capsec-tests/tests/compile_fail/has_forgery_process_exit.rs deleted file mode 100644 index 6eeed88..0000000 --- a/crates/capsec-tests/tests/compile_fail/has_forgery_process_exit.rs +++ /dev/null @@ -1,16 +0,0 @@ -/// Forgery via process::exit() divergence is now blocked by Has

being sealed. -use capsec::prelude::*; - -struct ExitForgery; - -impl Has for ExitForgery { - fn cap_ref(&self) -> Cap { - std::process::exit(0) - } -} - -fn main() { - let forge = ExitForgery; - fn needs_fs_read(_cap: &impl Has) {} - needs_fs_read(&forge); -} diff --git a/crates/capsec-tests/tests/compile_fail/has_forgery_process_exit.stderr b/crates/capsec-tests/tests/compile_fail/has_forgery_process_exit.stderr deleted file mode 100644 index ef02135..0000000 --- a/crates/capsec-tests/tests/compile_fail/has_forgery_process_exit.stderr +++ /dev/null @@ -1,37 +0,0 @@ -error[E0277]: the trait bound `ExitForgery: capsec_core::has::sealed::Sealed` is not satisfied - --> tests/compile_fail/has_forgery_process_exit.rs:6:22 - | -6 | impl Has for ExitForgery { - | ^^^^^^^^^^^ unsatisfied trait bound - | -help: the trait `capsec_core::has::sealed::Sealed` is not implemented for `ExitForgery` - --> tests/compile_fail/has_forgery_process_exit.rs:4:1 - | -4 | struct ExitForgery; - | ^^^^^^^^^^^^^^^^^^ - = help: the following other types implement trait `capsec_core::has::sealed::Sealed

`: - `capsec::Cap<(Ambient, Ambient)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvRead)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvRead)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvWrite)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvWrite)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsAll)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsAll)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsWrite)>` implements `capsec_core::has::sealed::Sealed` - and $N others -note: required by a bound in `capsec::Has` - --> $WORKSPACE/crates/capsec-core/src/has.rs - | - | pub trait Has: sealed::Sealed

{ - | ^^^^^^^^^^^^^^^^^ required by this bound in `Has` - = note: `Has` is a "sealed trait", because to implement it you also need to implement `capsec_core::has::sealed::Sealed`, which is not accessible; this is usually done to force you to use one of the provided types that already implement it - = help: the following types implement the trait: - capsec::Cap

- capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - and $N others diff --git a/crates/capsec-tests/tests/compile_fail/sealed_has_no_external_impl.rs b/crates/capsec-tests/tests/compile_fail/sealed_has_no_external_impl.rs deleted file mode 100644 index 20e39d0..0000000 --- a/crates/capsec-tests/tests/compile_fail/sealed_has_no_external_impl.rs +++ /dev/null @@ -1,13 +0,0 @@ -/// The Has

trait is sealed — external crates cannot implement it. -/// This prevents forging capability proofs outside capsec-core. -use capsec::prelude::*; - -struct FakeCap; - -impl Has for FakeCap { - fn cap_ref(&self) -> Cap { - loop {} - } -} - -fn main() {} diff --git a/crates/capsec-tests/tests/compile_fail/sealed_has_no_external_impl.stderr b/crates/capsec-tests/tests/compile_fail/sealed_has_no_external_impl.stderr deleted file mode 100644 index 7e071ad..0000000 --- a/crates/capsec-tests/tests/compile_fail/sealed_has_no_external_impl.stderr +++ /dev/null @@ -1,37 +0,0 @@ -error[E0277]: the trait bound `FakeCap: capsec_core::has::sealed::Sealed` is not satisfied - --> tests/compile_fail/sealed_has_no_external_impl.rs:7:22 - | -7 | impl Has for FakeCap { - | ^^^^^^^ unsatisfied trait bound - | -help: the trait `capsec_core::has::sealed::Sealed` is not implemented for `FakeCap` - --> tests/compile_fail/sealed_has_no_external_impl.rs:5:1 - | -5 | struct FakeCap; - | ^^^^^^^^^^^^^^ - = help: the following other types implement trait `capsec_core::has::sealed::Sealed

`: - `capsec::Cap<(Ambient, Ambient)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvRead)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvRead)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvWrite)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, EnvWrite)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsAll)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsAll)>` implements `capsec_core::has::sealed::Sealed` - `capsec::Cap<(Ambient, FsWrite)>` implements `capsec_core::has::sealed::Sealed` - and $N others -note: required by a bound in `capsec::Has` - --> $WORKSPACE/crates/capsec-core/src/has.rs - | - | pub trait Has: sealed::Sealed

{ - | ^^^^^^^^^^^^^^^^^ required by this bound in `Has` - = note: `Has` is a "sealed trait", because to implement it you also need to implement `capsec_core::has::sealed::Sealed`, which is not accessible; this is usually done to force you to use one of the provided types that already implement it - = help: the following types implement the trait: - capsec::Cap

- capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - capsec::Cap - and $N others diff --git a/crates/capsec-tests/tests/type_system.rs b/crates/capsec-tests/tests/type_system.rs index 1a89f48..14cf432 100644 --- a/crates/capsec-tests/tests/type_system.rs +++ b/crates/capsec-tests/tests/type_system.rs @@ -2,14 +2,13 @@ //! //! Each test attempts to forge, escalate, or bypass capability tokens. //! -//! ## Has

forgery (sections A + B) — NOW BLOCKED +//! ## Has

trait — open for delegation //! -//! The `Has

` trait is now sealed. The 7 forgery tests that previously -//! demonstrated the vulnerability have been moved to compile-fail tests in -//! `tests/compile_fail/has_forgery_*.rs`. They now prove the fix works by -//! failing to compile. +//! `Has

` is now open so user-defined context structs can implement it. +//! Security is maintained because `Cap::new()` is `pub(crate)` — no external +//! code can forge a `Cap

` in safe Rust. -use capsec_core::cap::Cap; +use capsec_core::cap::{Cap, SendCap}; use capsec_core::has::Has; use capsec_core::permission::*; use capsec_core::root::test_root; @@ -274,3 +273,139 @@ fn no_cross_category_leak() { // fn needs_net(_cap: &impl Has) {} // needs_net(&fs_all); // ERROR } + +// ============================================================================ +// G. CONTEXT DELEGATION (Has

is open for user structs) +// ============================================================================ + +/// User-defined struct with a Cap field can implement Has +/// and pass to functions requiring that capability. +#[test] +fn context_struct_satisfies_has() { + struct MyContext { + fs: Cap, + } + + impl Has for MyContext { + fn cap_ref(&self) -> Cap { + self.fs.cap_ref() + } + } + + let root = test_root(); + let ctx = MyContext { + fs: root.grant::(), + }; + + fn needs_fs(_: &impl Has) {} + needs_fs(&ctx); +} + +/// SendCap

satisfies Has

via the blanket impl. +#[test] +fn sendcap_satisfies_has() { + let root = test_root(); + let send_cap: SendCap = root.grant::().make_send(); + + fn needs_fs(_: &impl Has) {} + needs_fs(&send_cap); +} + +/// SendCap

.cap_ref() returns a valid Cap

. +#[test] +fn sendcap_has_delegation_runtime() { + let root = test_root(); + let send_cap: SendCap = root.grant::().make_send(); + + let _proof: Cap = send_cap.cap_ref(); +} + +// ============================================================================ +// H. CAPSEC::RUN +// ============================================================================ + +#[test] +fn capsec_run_provides_root() { + // Can't use capsec::run() because it calls the singleton root(). + // Instead test the pattern directly. + let root = test_root(); + let _cap = root.fs_read(); +} + +// ============================================================================ +// I. CONTEXT MACRO +// ============================================================================ + +#[capsec_macro::context] +struct TestCtx { + fs: FsRead, + net: NetConnect, +} + +#[test] +fn context_macro_satisfies_has() { + let root = test_root(); + let ctx = TestCtx::new(&root); + + fn needs_fs(_: &impl Has) {} + fn needs_net(_: &impl Has) {} + needs_fs(&ctx); + needs_net(&ctx); +} + +#[test] +fn context_macro_works_with_capsec_std() { + let root = test_root(); + let ctx = TestCtx::new(&root); + + let result = capsec_std::fs::read("/dev/null", &ctx); + assert!(result.is_ok()); +} + +#[capsec_macro::context(send)] +struct SendCtx { + fs: FsRead, +} + +#[test] +fn send_context_is_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); +} + +#[test] +fn send_context_satisfies_has() { + let root = test_root(); + let ctx = SendCtx::new(&root); + + fn needs_fs(_: &impl Has) {} + needs_fs(&ctx); +} + +// ============================================================================ +// J. REQUIRES WITH ON = PARAM +// ============================================================================ + +#[capsec_macro::requires(fs::read, on = ctx)] +fn fn_with_requires_on(ctx: &TestCtx) { + let _: Cap = ctx.cap_ref(); +} + +#[test] +fn requires_on_compiles_with_valid_context() { + let root = test_root(); + let ctx = TestCtx::new(&root); + fn_with_requires_on(&ctx); +} + +#[capsec_macro::requires(fs::read)] +fn fn_with_requires_impl(cap: &impl Has) { + let _: Cap = cap.cap_ref(); +} + +#[test] +fn requires_impl_still_works() { + let root = test_root(); + let cap = root.fs_read(); + fn_with_requires_impl(&cap); +} diff --git a/crates/capsec/Cargo.toml b/crates/capsec/Cargo.toml index 476265d..0af5912 100644 --- a/crates/capsec/Cargo.toml +++ b/crates/capsec/Cargo.toml @@ -19,3 +19,4 @@ capsec-std.workspace = true [dev-dependencies] trybuild.workspace = true capsec-core = { workspace = true, features = ["test-support"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/crates/capsec/README.md b/crates/capsec/README.md index 2c23827..310d18d 100644 --- a/crates/capsec/README.md +++ b/crates/capsec/README.md @@ -15,33 +15,36 @@ cargo add capsec ```rust,ignore use capsec::prelude::*; -fn main() { - let root = capsec::root(); - let fs_cap = root.grant::(); +#[capsec::context] +struct AppCtx { + fs: FsRead, + net: NetConnect, +} - let data = load_data("/tmp/data.csv", &fs_cap).unwrap(); - let result = transform(&data); +#[capsec::main] +fn main(root: CapRoot) { + let ctx = AppCtx::new(&root); + let data = load_data("/tmp/data.csv", &ctx).unwrap(); } -// Requires filesystem read — enforced by the compiler +// Leaf functions take &impl Has

— works with raw caps AND context structs fn load_data(path: &str, cap: &impl Has) -> Result { capsec::fs::read_to_string(path, cap) } - -// No capability token — this function cannot do I/O -fn transform(input: &str) -> String { - input.to_uppercase() -} ``` ## What's re-exported | From | What you get | |------|-------------| -| `capsec-core` | `Cap`, `Has`, `Permission`, `CapRoot`, `FsRead`, `NetConnect`, etc. | -| `capsec-macro` | `#[capsec::requires(...)]`, `#[capsec::deny(...)]` | +| `capsec-core` | `Cap`, `SendCap`, `Has`, `Permission`, `CapRoot`, `FsRead`, `NetConnect`, etc. | +| `capsec-macro` | `#[capsec::requires]`, `#[capsec::deny]`, `#[capsec::main]`, `#[capsec::context]` | | `capsec-std` | `capsec::fs`, `capsec::net`, `capsec::env`, `capsec::process` | +Also provides: +- `capsec::run(|root| { ... })` — convenience entry point +- `capsec::prelude::*` — common imports + ## Testing Use `capsec::test_root()` (requires the `test-support` feature) to bypass the singleton check in tests: @@ -53,4 +56,4 @@ capsec = { version = "0.1", features = ["test-support"] } ## License -MIT OR Apache-2.0 +Apache-2.0 diff --git a/crates/capsec/examples/async_context.rs b/crates/capsec/examples/async_context.rs new file mode 100644 index 0000000..9e3a563 --- /dev/null +++ b/crates/capsec/examples/async_context.rs @@ -0,0 +1,42 @@ +//! Example: Async context with `#[capsec::context(send)]` +//! +//! Shows how to use capability contexts in async/threaded code. +//! The `send` variant uses `SendCap

` fields, making the context +//! `Send + Sync` so it can be wrapped in `Arc` and shared across tasks. + +use capsec::CapSecError; +use std::sync::Arc; + +/// A Send + Sync context for async use. +/// The `send` option generates `SendCap

` fields instead of `Cap

`. +#[capsec::context(send)] +struct AppCtx { + fs: FsRead, +} + +/// Simulates handling a request. The context is shared via Arc. +async fn handle_request(id: usize, ctx: &AppCtx) -> Result<(), CapSecError> { + let data = capsec::fs::read_to_string("/etc/hostname", ctx)?; + println!("[task {id}] hostname: {}", data.trim()); + Ok(()) +} + +#[capsec::main] +#[tokio::main] +async fn main(root: CapRoot) { + let ctx = Arc::new(AppCtx::new(&root)); + + let mut handles = Vec::new(); + for i in 0..3 { + let ctx = ctx.clone(); + handles.push(tokio::spawn(async move { + handle_request(i, &ctx).await.ok(); + })); + } + + for h in handles { + h.await.unwrap(); + } + + println!("All tasks complete."); +} diff --git a/crates/capsec/examples/context_pattern.rs b/crates/capsec/examples/context_pattern.rs new file mode 100644 index 0000000..1908f40 --- /dev/null +++ b/crates/capsec/examples/context_pattern.rs @@ -0,0 +1,64 @@ +//! Example: Sub-contexts for least privilege +//! +//! Shows how to define narrow context structs for different subsystems. +//! Each context carries only the permissions its subsystem needs — +//! the compiler enforces the boundary. + +use capsec::prelude::*; + +// ─── Narrow contexts per subsystem ────────────────────────────── + +/// Ingest subsystem: can read files but not write them. +#[capsec::context] +struct IngestCtx { + fs: FsRead, +} + +/// Output subsystem: can write files and connect to the network. +#[capsec::context] +struct OutputCtx { + fs: FsWrite, + net: NetConnect, +} + +// ─── Subsystem functions ──────────────────────────────────────── + +/// Reads input data. Cannot write files or connect to the network. +fn ingest(path: &str, ctx: &IngestCtx) -> Result { + capsec::fs::read_to_string(path, ctx) +} + +/// Writes output. Cannot read files. +fn publish(path: &str, data: &str, ctx: &OutputCtx) -> Result<(), CapSecError> { + capsec::fs::write(path, data.as_bytes(), ctx) +} + +/// Pure transformation — no context, no I/O. +fn transform(data: &str) -> String { + data.lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| format!(" > {l}")) + .collect::>() + .join("\n") +} + +// ─── Entry point ──────────────────────────────────────────────── + +#[capsec::main] +fn main(root: CapRoot) -> Result<(), Box> { + // Each subsystem gets exactly the authority it needs. + let ingest_ctx = IngestCtx::new(&root); + let output_ctx = OutputCtx::new(&root); + + let raw = ingest("/etc/hostname", &ingest_ctx)?; + let processed = transform(&raw); + publish("/tmp/capsec-context-demo.txt", &processed, &output_ctx)?; + + // The compiler prevents: + // - ingest() from writing files (IngestCtx has no FsWrite) + // - publish() from reading files (OutputCtx has no FsRead) + // - transform() from doing any I/O (no context at all) + + println!("Processed {} bytes -> {} bytes", raw.len(), processed.len()); + Ok(()) +} diff --git a/crates/capsec/examples/context_struct.rs b/crates/capsec/examples/context_struct.rs index 128be71..41ca914 100644 --- a/crates/capsec/examples/context_struct.rs +++ b/crates/capsec/examples/context_struct.rs @@ -1,79 +1,58 @@ -//! Example: Context Struct pattern +//! Example: Context Struct pattern with `#[capsec::context]` //! //! Demonstrates bundling capabilities into a single struct to reduce -//! parameter count at intermediate layers. -//! -//! Instead of threading 3+ individual `Cap

` parameters through every -//! function, group them into a context struct and pass that. Leaf functions -//! still take `&impl Has

` — the struct is for intermediate layers only. +//! parameter count at intermediate layers. The `#[capsec::context]` macro +//! generates `Has

` implementations so the struct can be passed directly +//! to any capsec-gated function. //! //! Key concepts: +//! - `#[capsec::context]` generates Cap fields, constructor, and Has impls //! - Context structs are ZSTs (all fields are zero-sized caps) -//! - Pass `&ctx.field` to leaf functions that take `&impl Has

` -//! - For multi-threaded contexts, use `SendCap

` fields instead -//! -//! See also: `domain_wrapper.rs` for hiding caps entirely, and -//! `layered_app.rs` for combining both patterns. +//! - Pass `&ctx` directly to leaf functions that take `&impl Has

` +//! - For multi-threaded contexts, use `#[capsec::context(send)]` use capsec::prelude::*; // ─── The Context Struct ────────────────────────────────────────── /// Bundles the capabilities this application needs. -/// -/// All fields are `Cap

` — zero-sized types. This struct itself -/// is zero-sized at runtime. No allocation, no overhead. -#[allow(dead_code)] // fields shown for completeness — not all used in this demo -struct AppCaps { - fs_read: Cap, - fs_write: Cap, - net: Cap, - env: Cap, +/// The macro generates Cap

fields, a `new(root)` constructor, +/// and Has

impls for each permission. +#[capsec::context] +struct AppCtx { + fs_read: FsRead, + fs_write: FsWrite, + net: NetConnect, + env: EnvRead, } // ─── Intermediate layer: receives the context ──────────────────── /// Orchestrates the application workflow. -/// -/// Takes one `&AppCaps` instead of four separate capability parameters. -/// Extracts specific caps when calling leaf functions. -fn run_app(caps: &AppCaps) { - // Read config path from environment, then load the config file - let config_path = get_config_path(&caps.env); - let config = load_file(&config_path, &caps.fs_read); - - // Process (pure — no caps needed) +/// Takes one `&AppCtx` instead of four separate capability parameters. +/// The context satisfies Has

for each permission, so it can be +/// passed directly to leaf functions. +fn run_app(ctx: &AppCtx) { + let config_path = get_config_path(ctx); // ctx satisfies Has + let config = load_file(&config_path, ctx); // ctx satisfies Has + let processed = config.to_uppercase(); - // Write output - save_output("/tmp/capsec-demo-output.txt", &processed, &caps.fs_write); + save_output("/tmp/capsec-demo-output.txt", &processed, ctx); // ctx satisfies Has - // Report (would connect to a metrics server in a real app) println!("Would send {} bytes to metrics server", processed.len()); - // In real code: send_metrics("metrics:9090", &processed, &caps.net); } // ─── Leaf functions: take `&impl Has

`, not the context ──────── -/// Reads an environment variable. -/// -/// Takes `&impl Has` — doesn't know about AppCaps. -/// This keeps the function reusable and auditable: `grep Has` -/// finds every function that reads env vars. fn get_config_path(cap: &impl Has) -> String { capsec::env::var("APP_CONFIG", cap).unwrap_or_else(|_| "/etc/app/config.toml".into()) } -/// Reads a file from disk. -/// -/// Takes `&impl Has` — minimum required capability. fn load_file(path: &str, cap: &impl Has) -> String { capsec::fs::read_to_string(path, cap).unwrap_or_else(|_| "# default config".into()) } -/// Writes data to a file. -/// -/// Takes `&impl Has` — cannot read files. fn save_output(path: &str, data: &str, cap: &impl Has) { if let Err(e) = capsec::fs::write(path, data.as_bytes(), cap) { eprintln!("Warning: could not write {path}: {e}"); @@ -82,20 +61,12 @@ fn save_output(path: &str, data: &str, cap: &impl Has) { // ─── Entry point ───────────────────────────────────────────────── -fn main() { - let root = capsec::root(); - - // Build the context struct — this is the single place where - // capabilities are granted and grouped. - let caps = AppCaps { - fs_read: root.grant::(), - fs_write: root.grant::(), - net: root.grant::(), - env: root.grant::(), - }; +#[capsec::main] +fn main(root: CapRoot) { + let ctx = AppCtx::new(&root); // One parameter instead of four through the call stack. - run_app(&caps); + run_app(&ctx); - println!("Done. The context struct reduced 4 cap params to 1."); + println!("Done. The #[capsec::context] macro eliminated all boilerplate."); } diff --git a/crates/capsec/examples/incremental_migration.rs b/crates/capsec/examples/incremental_migration.rs index 8a66687..a93a188 100644 --- a/crates/capsec/examples/incremental_migration.rs +++ b/crates/capsec/examples/incremental_migration.rs @@ -30,23 +30,12 @@ fn send_metrics(addr: &str, data: &str, cap: &impl Has) -> Result<() // ─── Not yet migrated ─────────────────────────────────────────────── /// This function still uses `std::fs` directly — it works fine, but -/// `cargo capsec audit` will flag it: -/// -/// ```text -/// FS examples/incremental_migration.rs:41:5 std::fs::write save_cache() -/// ``` -/// -/// When you're ready, convert it to take `cap: &impl Has` and -/// call `capsec::fs::write()` instead. +/// `cargo capsec audit` will flag it. fn save_cache(path: &str, data: &str) { std::fs::write(path, data).expect("cache write failed"); } -/// Also not yet migrated. `cargo capsec audit` flags: -/// -/// ```text -/// ENV examples/incremental_migration.rs:52:5 std::env::var get_log_level() -/// ``` +/// Also not yet migrated. `cargo capsec audit` flags env var access. fn get_log_level() -> String { std::env::var("LOG_LEVEL").unwrap_or_else(|_| "info".into()) } @@ -58,18 +47,16 @@ fn format_report(config: &str, level: &str) -> String { format!("[{level}] config loaded: {}", config.len()) } -fn main() -> Result<(), Box> { - let root = capsec::root(); - - // Grant capabilities for migrated functions. - let fs_read = root.grant::(); - let net_cap = root.grant::(); +#[capsec::main] +fn main(root: CapRoot) -> Result<(), Box> { + // Convenience methods for migrated functions + let fs_read = root.fs_read(); + let net_cap = root.net_connect(); // Migrated: capabilities enforced at compile time. let config = load_config("/etc/app/config.toml", &fs_read)?; // Not yet migrated: still uses ambient authority. - // `cargo capsec audit` will flag these until they're converted. let level = get_log_level(); save_cache("/tmp/app.cache", &config); diff --git a/crates/capsec/examples/type_enforcement.rs b/crates/capsec/examples/type_enforcement.rs index 04ad0e4..0379719 100644 --- a/crates/capsec/examples/type_enforcement.rs +++ b/crates/capsec/examples/type_enforcement.rs @@ -5,8 +5,8 @@ //! that functions can only do what their signature declares. //! //! Key concepts shown here: -//! - `capsec::root()` — the single point of authority in main() -//! - `root.grant::

()` — create a capability token for permission P +//! - `#[capsec::main]` — injects the capability root automatically +//! - Convenience methods (`root.fs_read()`) — discoverable via IDE autocomplete //! - `&impl Has

` — function bounds that declare required permissions //! - `capsec::fs::*`, `capsec::net::*`, `capsec::process::*` — drop-in //! replacements for std that require a capability argument @@ -48,21 +48,17 @@ fn run_cleanup(dir: &str, cap: &impl Has) -> Result<(), CapSecError> { } /// Pure computation — no capability parameter, no I/O possible. -/// The compiler guarantees this function cannot touch the filesystem, -/// network, or environment. fn process_data(input: &str) -> String { input.to_uppercase() } -fn main() -> Result<(), Box> { - // root() is the single point of authority. It can only be called once. - let root = capsec::root(); - - // Grant exactly the capabilities each function needs. - let fs_read = root.grant::(); - let fs_write = root.grant::(); - let net_cap = root.grant::(); - let spawn_cap = root.grant::(); +#[capsec::main] +fn main(root: CapRoot) -> Result<(), Box> { + // Convenience methods — no turbofish needed + let fs_read = root.fs_read(); + let fs_write = root.fs_write(); + let net_cap = root.net_connect(); + let spawn_cap = root.spawn(); // Each function receives only the capability it needs. let config = load_config("/etc/app/config.toml", &fs_read)?; @@ -71,12 +67,5 @@ fn main() -> Result<(), Box> { send_report("telemetry.example.com:8080", &result, &net_cap)?; run_cleanup("/tmp/scratch", &spawn_cap)?; - // What you gain: - // - load_config() can read files but NOT write, connect, or spawn. - // - save_result() can write files but NOT read, connect, or spawn. - // - send_report() can connect but NOT touch files or spawn processes. - // - process_data() can do NOTHING — it's provably pure. - // - All of this is checked at compile time, with zero runtime cost. - Ok(()) } diff --git a/crates/capsec/src/lib.rs b/crates/capsec/src/lib.rs index 19cfdfa..fea95bd 100644 --- a/crates/capsec/src/lib.rs +++ b/crates/capsec/src/lib.rs @@ -25,7 +25,7 @@ //! This is a facade crate that re-exports from three internal crates: //! //! - **`capsec-core`** — capability tokens, permission traits, composition -//! - **`capsec-macro`** — `#[requires]` and `#[deny]` proc macros +//! - **`capsec-macro`** — `#[requires]`, `#[deny]`, `#[main]`, and `#[context]` proc macros //! - **`capsec-std`** — capability-gated `std` wrappers // Re-exports from capsec-core @@ -44,9 +44,17 @@ pub use capsec_core::root::test_root; pub use capsec_core::attenuate::{Attenuated, DirScope, HostScope, Scope}; +/// Creates a `CapRoot` and passes it to the given closure. +/// +/// This is a convenience entry point. Panics if `root()` has already been called. +pub fn run(f: impl FnOnce(CapRoot) -> T) -> T { + let root = root(); + f(root) +} + // Re-exports from capsec-macro -pub use capsec_macro::{deny, requires}; +pub use capsec_macro::{context, deny, main, requires}; // Capability-gated std wrappers diff --git a/crates/capsec/tests/compile_fail/context_bad_field.rs b/crates/capsec/tests/compile_fail/context_bad_field.rs new file mode 100644 index 0000000..177788f --- /dev/null +++ b/crates/capsec/tests/compile_fail/context_bad_field.rs @@ -0,0 +1,9 @@ +/// #[capsec::context] rejects non-permission field types. +use capsec::prelude::*; + +#[capsec::context] +struct Bad { + x: String, +} + +fn main() {} diff --git a/crates/capsec/tests/compile_fail/context_bad_field.stderr b/crates/capsec/tests/compile_fail/context_bad_field.stderr new file mode 100644 index 0000000..a2a893e --- /dev/null +++ b/crates/capsec/tests/compile_fail/context_bad_field.stderr @@ -0,0 +1,13 @@ +error: field 'x' has type 'String', which is not a capsec permission type. Expected one of: FsRead, FsWrite, FsAll, NetConnect, NetBind, NetAll, EnvRead, EnvWrite, Spawn, Ambient + --> tests/compile_fail/context_bad_field.rs:6:8 + | +6 | x: String, + | ^^^^^^ + +warning: unused import: `capsec::prelude::*` + --> tests/compile_fail/context_bad_field.rs:2:5 + | +2 | use capsec::prelude::*; + | ^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/capsec/tests/compile_fail/context_duplicate_perm.rs b/crates/capsec/tests/compile_fail/context_duplicate_perm.rs new file mode 100644 index 0000000..1a35dc3 --- /dev/null +++ b/crates/capsec/tests/compile_fail/context_duplicate_perm.rs @@ -0,0 +1,10 @@ +/// #[capsec::context] rejects duplicate permission types. +use capsec::prelude::*; + +#[capsec::context] +struct Bad { + a: FsRead, + b: FsRead, +} + +fn main() {} diff --git a/crates/capsec/tests/compile_fail/context_duplicate_perm.stderr b/crates/capsec/tests/compile_fail/context_duplicate_perm.stderr new file mode 100644 index 0000000..8e673f1 --- /dev/null +++ b/crates/capsec/tests/compile_fail/context_duplicate_perm.stderr @@ -0,0 +1,13 @@ +error: duplicate permission type 'FsRead' — each permission can only appear once in a context struct + --> tests/compile_fail/context_duplicate_perm.rs:7:8 + | +7 | b: FsRead, + | ^^^^^^ + +warning: unused import: `capsec::prelude::*` + --> tests/compile_fail/context_duplicate_perm.rs:2:5 + | +2 | use capsec::prelude::*; + | ^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/capsec/tests/compile_fail/context_tuple_struct.rs b/crates/capsec/tests/compile_fail/context_tuple_struct.rs new file mode 100644 index 0000000..5627191 --- /dev/null +++ b/crates/capsec/tests/compile_fail/context_tuple_struct.rs @@ -0,0 +1,7 @@ +/// #[capsec::context] rejects tuple structs. +use capsec::prelude::*; + +#[capsec::context] +struct Bad(FsRead); + +fn main() {} diff --git a/crates/capsec/tests/compile_fail/context_tuple_struct.stderr b/crates/capsec/tests/compile_fail/context_tuple_struct.stderr new file mode 100644 index 0000000..626f40e --- /dev/null +++ b/crates/capsec/tests/compile_fail/context_tuple_struct.stderr @@ -0,0 +1,13 @@ +error: #[capsec::context] requires a struct with named fields + --> tests/compile_fail/context_tuple_struct.rs:5:1 + | +5 | struct Bad(FsRead); + | ^^^^^^^^^^^^^^^^^^^ + +warning: unused import: `capsec::prelude::*` + --> tests/compile_fail/context_tuple_struct.rs:2:5 + | +2 | use capsec::prelude::*; + | ^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/capsec/tests/compile_fail/main_no_params.rs b/crates/capsec/tests/compile_fail/main_no_params.rs new file mode 100644 index 0000000..c2ac16c --- /dev/null +++ b/crates/capsec/tests/compile_fail/main_no_params.rs @@ -0,0 +1,5 @@ +/// #[capsec::main] requires a CapRoot parameter. +use capsec::prelude::*; + +#[capsec::main] +fn main() {} diff --git a/crates/capsec/tests/compile_fail/main_no_params.stderr b/crates/capsec/tests/compile_fail/main_no_params.stderr new file mode 100644 index 0000000..e2ddccf --- /dev/null +++ b/crates/capsec/tests/compile_fail/main_no_params.stderr @@ -0,0 +1,19 @@ +error: #[capsec::main] expected first parameter of type CapRoot + --> tests/compile_fail/main_no_params.rs:5:1 + | +5 | fn main() {} + | ^^^^^^^^^ + +warning: unused import: `capsec::prelude::*` + --> tests/compile_fail/main_no_params.rs:2:5 + | +2 | use capsec::prelude::*; + | ^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + +error[E0601]: `main` function not found in crate `$CRATE` + --> tests/compile_fail/main_no_params.rs:5:13 + | +5 | fn main() {} + | ^ consider adding a `main` function to `$DIR/tests/compile_fail/main_no_params.rs` diff --git a/crates/capsec/tests/compile_fail/main_wrong_type.rs b/crates/capsec/tests/compile_fail/main_wrong_type.rs new file mode 100644 index 0000000..edb2da3 --- /dev/null +++ b/crates/capsec/tests/compile_fail/main_wrong_type.rs @@ -0,0 +1,5 @@ +/// #[capsec::main] first parameter must be CapRoot. +use capsec::prelude::*; + +#[capsec::main] +fn main(x: String) {} diff --git a/crates/capsec/tests/compile_fail/main_wrong_type.stderr b/crates/capsec/tests/compile_fail/main_wrong_type.stderr new file mode 100644 index 0000000..398c98d --- /dev/null +++ b/crates/capsec/tests/compile_fail/main_wrong_type.stderr @@ -0,0 +1,19 @@ +error: first parameter must be CapRoot + --> tests/compile_fail/main_wrong_type.rs:5:12 + | +5 | fn main(x: String) {} + | ^^^^^^ + +warning: unused import: `capsec::prelude::*` + --> tests/compile_fail/main_wrong_type.rs:2:5 + | +2 | use capsec::prelude::*; + | ^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + +error[E0601]: `main` function not found in crate `$CRATE` + --> tests/compile_fail/main_wrong_type.rs:5:22 + | +5 | fn main(x: String) {} + | ^ consider adding a `main` function to `$DIR/tests/compile_fail/main_wrong_type.rs` diff --git a/crates/capsec/tests/compile_fail/requires_concrete_no_on.rs b/crates/capsec/tests/compile_fail/requires_concrete_no_on.rs new file mode 100644 index 0000000..484c181 --- /dev/null +++ b/crates/capsec/tests/compile_fail/requires_concrete_no_on.rs @@ -0,0 +1,9 @@ +/// #[capsec::requires] with concrete types requires `on = param`. +use capsec::prelude::*; + +struct AppCtx; + +#[capsec::requires(fs::read)] +fn process(ctx: &AppCtx) {} + +fn main() {} diff --git a/crates/capsec/tests/compile_fail/requires_concrete_no_on.stderr b/crates/capsec/tests/compile_fail/requires_concrete_no_on.stderr new file mode 100644 index 0000000..7cf2b65 --- /dev/null +++ b/crates/capsec/tests/compile_fail/requires_concrete_no_on.stderr @@ -0,0 +1,14 @@ +error: #[capsec::requires] on a function with concrete parameter types requires `on = ` to identify the capability parameter. + Example: #[capsec::requires(fs::read, on = ctx)] + --> tests/compile_fail/requires_concrete_no_on.rs:7:1 + | +7 | fn process(ctx: &AppCtx) {} + | ^^^^^^^^^^^^^^^^^^^^^^^^ + +warning: unused import: `capsec::prelude::*` + --> tests/compile_fail/requires_concrete_no_on.rs:2:5 + | +2 | use capsec::prelude::*; + | ^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/capsec/tests/compile_fail/requires_on_wrong_type.rs b/crates/capsec/tests/compile_fail/requires_on_wrong_type.rs new file mode 100644 index 0000000..2636922 --- /dev/null +++ b/crates/capsec/tests/compile_fail/requires_on_wrong_type.rs @@ -0,0 +1,9 @@ +/// #[capsec::requires(fs::read, on = ctx)] fails when ctx type lacks Has. +use capsec::prelude::*; + +struct NoHas; + +#[capsec::requires(fs::read, on = ctx)] +fn process(ctx: &NoHas) {} + +fn main() {} diff --git a/crates/capsec/tests/compile_fail/requires_on_wrong_type.stderr b/crates/capsec/tests/compile_fail/requires_on_wrong_type.stderr new file mode 100644 index 0000000..3d4f878 --- /dev/null +++ b/crates/capsec/tests/compile_fail/requires_on_wrong_type.stderr @@ -0,0 +1,43 @@ +warning: unused import: `capsec::prelude::*` + --> tests/compile_fail/requires_on_wrong_type.rs:2:5 + | +2 | use capsec::prelude::*; + | ^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + +error[E0277]: the trait bound `NoHas: Has` is not satisfied + --> tests/compile_fail/requires_on_wrong_type.rs:7:18 + | +7 | fn process(ctx: &NoHas) {} + | ^^^^^ unsatisfied trait bound + | +help: the trait `Has` is not implemented for `NoHas` + --> tests/compile_fail/requires_on_wrong_type.rs:4:1 + | +4 | struct NoHas; + | ^^^^^^^^^^^^ + = help: the following other types implement trait `Has

`: + `Cap<(Ambient, Ambient)>` implements `Has` + `Cap<(Ambient, EnvRead)>` implements `Has` + `Cap<(Ambient, EnvRead)>` implements `Has` + `Cap<(Ambient, EnvWrite)>` implements `Has` + `Cap<(Ambient, EnvWrite)>` implements `Has` + `Cap<(Ambient, FsAll)>` implements `Has` + `Cap<(Ambient, FsAll)>` implements `Has` + `Cap<(Ambient, FsRead)>` implements `Has` + and $N others +note: required by a bound in `_assert_has_0` + --> tests/compile_fail/requires_on_wrong_type.rs:6:1 + | +6 | #[capsec::requires(fs::read, on = ctx)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `_assert_has_0` + = note: this error originates in the attribute macro `capsec::requires` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: unused variable: `ctx` + --> tests/compile_fail/requires_on_wrong_type.rs:7:12 + | +7 | fn process(ctx: &NoHas) {} + | ^^^ help: if this is intentional, prefix it with an underscore: `_ctx` + | + = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/cargo-capsec/README.md b/crates/cargo-capsec/README.md index b59a504..4847893 100644 --- a/crates/cargo-capsec/README.md +++ b/crates/cargo-capsec/README.md @@ -100,4 +100,4 @@ reason = "Logging framework, reviewed" ## License -MIT OR Apache-2.0 +Apache-2.0 diff --git a/ergonomics_spec.md b/ergonomics_spec.md new file mode 100644 index 0000000..90b20fa --- /dev/null +++ b/ergonomics_spec.md @@ -0,0 +1,825 @@ +# Ergonomics Spec: Making capsec Painless + +**Status:** Draft +**Authors:** capsec maintainers +**Date:** 2026-03-18 + +--- + +## Problem + +capsec's type-system enforcement is sound. Adopting it is not painless. There are three layers of friction, each compounding the one before it: + +### Friction 1: Entry-point ceremony + +Creating capabilities requires turbofish syntax and multi-step setup: + +```rust +fn main() { + let root = capsec::root(); // step 1: get root + let fs_cap = root.grant::(); // step 2: turbofish grant + let net_cap = root.grant::(); // step 3: another turbofish + let config = load_config("app.toml", &fs_cap); +} +``` + +`grant::()` is not discoverable via IDE autocomplete, not obvious to Rust newcomers, and requires importing each permission type by name. + +### Friction 2: The coloring problem + +Every function between `main()` and the leaf I/O call must thread capability parameters through its signature. This is the "function coloring" burden: + +```rust +fn application_logic( + config: &str, + fs_cap: &impl Has, // must carry this... + write_cap: &impl Has, // ...and this... + net_cap: &impl Has, // ...and this +) -> Result<(), CapSecError> { + let data = load_data("input.csv", fs_cap)?; + let result = transform(&data); + save_output(&result, write_cap)?; + notify_service(&result, net_cap)?; + Ok(()) +} +``` + +A function 5 levels deep in the call stack needs capability parameters it doesn't use directly, just to forward them. This is the same pain as manual dependency injection without a container. It scales linearly with the number of permission categories your app touches. + +### Friction 3: Pattern confusion + +New users face three adoption patterns (audit-only, type enforcement, incremental migration) and no guidance on which one to pick. The examples show the patterns but don't reduce the ceremony of any of them. + +--- + +## Design Principles + +1. **Zero ceremony for the common case.** A small app with 2-3 I/O categories should need ~5 lines of capsec setup, not 15. +2. **Capability threading should be invisible.** A function in the middle of the call stack should accept a single context reference, not N separate capability parameters. +3. **Leaf functions stay generic.** Functions that do actual I/O should still accept `&impl Has

`, so they work with raw caps, context structs, and any future capability holder. +4. **No security regression.** Convenience must not weaken the type-system guarantees. Unsealing `Has

` is safe because `Cap::new()` remains `pub(crate)` — there is no safe-code path to forging a capability token (see Security section). +5. **Additive, not breaking.** Every change is backwards-compatible. `root.grant::()` continues to work. + +--- + +## Proposal: Three Layers + +Each layer is independently useful. Together they eliminate all three friction sources. + +### Layer 1: Convenience methods on `CapRoot` + +**Solves:** Friction 1 (turbofish, discoverability) + +Add named methods directly on `CapRoot` for every built-in permission: + +```rust +impl CapRoot { + pub fn fs_read(&self) -> Cap { self.grant() } + pub fn fs_write(&self) -> Cap { self.grant() } + pub fn fs_all(&self) -> Cap { self.grant() } + pub fn net_connect(&self) -> Cap { self.grant() } + pub fn net_bind(&self) -> Cap { self.grant() } + pub fn net_all(&self) -> Cap { self.grant() } + pub fn env_read(&self) -> Cap { self.grant() } + pub fn env_write(&self) -> Cap { self.grant() } + pub fn spawn(&self) -> Cap { self.grant() } + pub fn ambient(&self) -> Cap { self.grant() } +} +``` + +**Before:** +```rust +let fs_cap = root.grant::(); +``` + +**After:** +```rust +let fs_cap = root.fs_read(); +``` + +Benefits: +- IDE autocomplete shows all available permissions after `root.` +- No turbofish syntax for the common case +- Self-documenting: `root.fs_read()` reads as intent +- `grant::

()` still works for tuples and advanced use cases + +**Files changed:** `capsec-core/src/root.rs` +**Effort:** S + +### Layer 2: `capsec::run()` and `#[capsec::main]` + +**Solves:** Friction 1 (boilerplate root creation) + +#### `capsec::run()` + +A functional entry point that handles root creation: + +```rust +// In capsec/src/lib.rs +pub fn run(f: impl FnOnce(CapRoot) -> T) -> T { + let root = root(); + f(root) +} +``` + +Usage: +```rust +fn main() { + capsec::run(|root| { + let fs = root.fs_read(); + let config = load_config("app.toml", &fs).unwrap(); + }); +} +``` + +#### `#[capsec::main]` + +Attribute macro that injects root creation: + +```rust +#[capsec::main] +fn main(root: CapRoot) -> Result<(), Box> { + let ctx = AppCtx::new(&root); + start_app(&ctx)?; + Ok(()) +} +``` + +Expands to: + +```rust +fn main() -> Result<(), Box> { + let root = capsec::root(); + let ctx = AppCtx::new(&root); + start_app(&ctx)?; + Ok(()) +} +``` + +The macro: +1. Extracts the first parameter (must be `CapRoot` or `capsec::CapRoot`) +2. Removes it from the function signature +3. Prepends `let {param_name} = capsec::root();` to the body +4. Preserves the return type and all other attributes + +#### Stacking with `#[tokio::main]` + +Rust proc macro attributes execute **bottom-up**. When combining `#[capsec::main]` with `#[tokio::main]`, the order matters: + +```rust +// CORRECT — #[capsec::main] runs first (bottom-up), removes the root param, +// then #[tokio::main] wraps the result in a runtime block. +#[tokio::main] +#[capsec::main] +async fn main(root: CapRoot) { ... } +``` + +```rust +// WRONG — #[tokio::main] runs first, transforms the async fn into a +// synchronous fn with a runtime block. #[capsec::main] then sees a +// parameterless main() and fails or produces wrong code. +#[capsec::main] +#[tokio::main] +async fn main(root: CapRoot) { ... } +``` + +The macro should detect `async fn` with zero parameters (indicating `#[tokio::main]` already ran) and emit a clear error: + +``` +error: #[capsec::main] must be placed below #[tokio::main], not above it. + Proc macro attributes execute bottom-up. + + Correct: + #[tokio::main] + #[capsec::main] + async fn main(root: CapRoot) { ... } +``` + +**Files changed:** `capsec-macro/src/lib.rs`, `capsec/src/lib.rs` +**Effort:** M + +### Layer 3: `#[capsec::context]` — the capability context macro + +**Solves:** Friction 2 (the coloring problem) + +This is the primary ergonomic improvement. A user defines a plain struct listing the permissions they need. The macro generates: +- A constructor that grants all capabilities from a `CapRoot` +- `Has

` implementations for each permission, so the struct can be passed directly to any capsec-gated function + +#### User writes: + +```rust +#[capsec::context] +pub struct AppCtx { + fs: FsRead, + write: FsWrite, + net: NetConnect, +} +``` + +#### Macro generates: + +```rust +pub struct AppCtx { + fs: Cap, + write: Cap, + net: Cap, +} + +impl AppCtx { + pub fn new(root: &CapRoot) -> Self { + Self { + fs: root.grant::(), + write: root.grant::(), + net: root.grant::(), + } + } +} + +impl Has for AppCtx { + fn cap_ref(&self) -> Cap { + self.fs.cap_ref() + } +} + +impl Has for AppCtx { + fn cap_ref(&self) -> Cap { + self.write.cap_ref() + } +} + +impl Has for AppCtx { + fn cap_ref(&self) -> Cap { + self.net.cap_ref() + } +} +``` + +#### The experience: + +```rust +#[capsec::context] +pub struct AppCtx { + fs: FsRead, + write: FsWrite, + net: NetConnect, +} + +#[capsec::main] +fn main(root: CapRoot) -> Result<(), Box> { + let ctx = AppCtx::new(&root); + application_logic(&ctx)?; + Ok(()) +} + +// ONE parameter, not three. Works at any call depth. +fn application_logic(ctx: &AppCtx) -> Result<(), CapSecError> { + let data = load_data("input.csv", ctx)?; // ctx satisfies Has + let result = transform(&data); + save_output(&result, ctx)?; // ctx satisfies Has + notify_service(&result, ctx)?; // ctx satisfies Has + Ok(()) +} + +// Leaf functions still use &impl Has

— they work with raw caps AND context structs. +fn load_data(path: &str, cap: &impl Has) -> Result { + capsec::fs::read_to_string(path, cap) +} +``` + +#### Async / threaded apps: `#[capsec::context(send)]` + +`Cap

` is `!Send + !Sync` by default. For apps using tokio, actix, or threads, the macro supports a `send` option that uses `SendCap

` instead: + +```rust +#[capsec::context(send)] +pub struct AppCtx { + fs: FsRead, + net: NetConnect, +} +``` + +Generates: + +```rust +pub struct AppCtx { + fs: SendCap, + net: SendCap, +} + +impl AppCtx { + pub fn new(root: &CapRoot) -> Self { + Self { + fs: root.grant::().make_send(), + net: root.grant::().make_send(), + } + } +} + +impl Has for AppCtx { + fn cap_ref(&self) -> Cap { + self.fs.as_cap() + } +} + +// ... etc +``` + +Now `AppCtx` is `Send + Sync` and can be wrapped in `Arc` for shared state: + +```rust +#[tokio::main] +#[capsec::main] +async fn main(root: CapRoot) { + let ctx = Arc::new(AppCtx::new(&root)); + + let ctx_clone = ctx.clone(); + tokio::spawn(async move { + handle_request(&*ctx_clone).await; + }); +} +``` + +#### Sub-contexts for least privilege + +The context pattern does NOT encourage over-granting. Users can define narrow contexts for different subsystems: + +```rust +#[capsec::context] +struct IngestCtx { + fs: FsRead, // can read, not write +} + +#[capsec::context] +struct OutputCtx { + fs: FsWrite, // can write, not read + net: NetConnect, // can connect +} + +fn ingest(ctx: &IngestCtx) -> Result { ... } +fn publish(ctx: &OutputCtx) -> Result<(), CapSecError> { ... } +``` + +The compiler enforces the boundary: `ingest()` cannot write files, `publish()` cannot read them. Each subsystem gets exactly the authority it needs. + +#### Interaction with scoping/attenuation + +The context is for *threading capabilities through call stacks*. It is not a replacement for `Attenuated` scoping. When you need path-restricted or host-restricted capabilities, extract the raw cap: + +```rust +fn scoped_read(ctx: &AppCtx) -> Result { + let raw_cap: Cap = ctx.cap_ref(); + let scoped = raw_cap.attenuate(DirScope::new("/var/data")?); + scoped.check("/var/data/input.csv")?; + capsec::fs::read_to_string("/var/data/input.csv", &raw_cap) +} +``` + +This is an intentional design boundary. Context bundles authority; scoping restricts it. + +#### Compile-time validation of field types + +The macro must validate that every field type is a known permission type. `Permission` is sealed — the set of valid types is fixed and exhaustive (`FsRead`, `FsWrite`, `FsAll`, `NetConnect`, `NetBind`, `NetAll`, `EnvRead`, `EnvWrite`, `Spawn`, `Ambient`). The macro checks each field's type path against this set at expansion time. + +If a user writes a non-permission type: + +```rust +#[capsec::context] +struct Bad { + x: String, +} +``` + +The macro emits a clear `compile_error!` at the field's span: + +``` +error: field `x` has type `String`, which is not a capsec permission type. + Expected one of: FsRead, FsWrite, FsAll, NetConnect, NetBind, + NetAll, EnvRead, EnvWrite, Spawn, Ambient + + --> src/main.rs:4:5 + | +4 | x: String, + | ^^^^^^^^^ +``` + +Without this validation, the generated `root.grant::()` would fail with a generic `String: Permission is not satisfied` error pointing at macro-generated code — confusing and unhelpful. The validation catches the mistake at the user's source location with an actionable message. + +**Tuple permission types are not supported in context structs.** While capsec supports `Cap<(FsRead, NetConnect)>` as a tuple permission, the context macro accepts only single permission types per field. Users who want multiple permissions use separate fields — this is clearer and produces one-to-one `Has

` impls per field: + +```rust +// Correct — one permission per field +#[capsec::context] +struct Ctx { + fs: FsRead, + net: NetConnect, +} + +// Not supported — tuple field type +#[capsec::context] +struct Ctx { + combo: (FsRead, NetConnect), // compile error +} +``` + +Tuple syntax would require the macro to destructure the type, generate `Cap<(A, B)>`, and emit `Has

` impls for both inner types from a single field. This is more complex to implement and harder to read. Flat fields are simpler, and the generated code is trivially auditable. Tuple support can be added in a future version if there's demand. + +**Files changed:** `capsec-macro/src/lib.rs`, `capsec-core/src/has.rs` +**Effort:** L + +--- + +## Required Change: Unsealing `Has

` + +### What and why + +`Has

` currently has a sealed supertrait (`capsec_core::has::sealed::Sealed

`). This prevents any type outside `capsec-core` from implementing `Has

`. The `#[capsec::context]` macro generates `impl Has

for UserStruct` in user code, which the sealed trait blocks. + +**The seal must be removed** for the context macro to work. This means changing: + +```rust +// Before +pub trait Has: sealed::Sealed

{ + fn cap_ref(&self) -> Cap

; +} +``` + +to: + +```rust +// After +pub trait Has { + fn cap_ref(&self) -> Cap

; +} +``` + +### Security analysis + +The `Has

` trait requires implementors to return a `Cap

` from `cap_ref()`. `Cap::new()` is `pub(crate)` — only code inside `capsec-core` can construct a capability token. This is the security boundary. + +**Why unsealing `Has

` is safe:** + +With `Has

` unsealed, external code can `impl Has for MyContext`. To satisfy the trait, the implementor must return a `Cap` from `cap_ref()`. There are exactly two ways to obtain a `Cap

`: + +1. **Legitimately, via `CapRoot::grant()`** — the implementor already holds the authority and is delegating it. This is the context pattern working as designed. +2. **Via `unsafe` code** — `transmute`, `zeroed`, `MaybeUninit`, or raw pointer tricks can forge a `Cap

`. All of these require an `unsafe` block, which places them outside capsec's safe-Rust threat model. The audit tool (`cargo capsec audit`) flags `unsafe` blocks and FFI. + +There is no safe-code path to constructing a `Cap

` outside of `capsec-core`. Therefore, there is no safe-code path to implementing `Has

` maliciously. The sealed trait was defense-in-depth on top of this already-airtight boundary. Removing it enables the context pattern without opening any new forgery vector in safe Rust. + +**The divergence case:** + +With `Has

` unsealed, someone could write a diverging implementation: + +```rust +struct Fake; +impl Has for Fake { + fn cap_ref(&self) -> Cap { panic!("forgery") } +} +``` + +This compiles, but it's not a useful attack. The `cap_ref()` call panics (or loops) before any I/O executes. And crucially, someone who can modify the codebase to add this impl could just call `std::fs::read()` directly — it's not an escalation of authority. Nevertheless, we harden the wrappers as belt-and-suspenders (see below). + +### Mitigation: Type-witnessed proof in capsec-std wrappers + +As defense-in-depth against diverging `cap_ref()` implementations, capsec-std wrappers should use a type-annotated binding that forces `cap_ref()` to actually return before any I/O executes. + +Currently, capsec-std wrappers call `cap_ref()` and discard the result: + +```rust +// Current — called but result discarded +pub fn read(path: impl AsRef, cap: &impl Has) -> Result, CapSecError> { + let _ = cap.cap_ref(); + Ok(std::fs::read(path)?) +} +``` + +Change every wrapper to use a type-annotated binding: + +```rust +// Proposed — type witness forces cap_ref() to actually return +pub fn read(path: impl AsRef, cap: &impl Has) -> Result, CapSecError> { + let _proof: Cap = cap.cap_ref(); + Ok(std::fs::read(path)?) +} +``` + +The `_proof: Cap` binding is zero-cost (ZST), but it forces `cap_ref()` to return a value of the correct type. A diverging `Has

` impl (`panic!()` or `loop {}`) fires *before* the I/O call, never after. This is a belt-and-suspenders guarantee: the primary security gate is `Cap::new()` being `pub(crate)`, and this ensures that even non-useful divergence attacks are caught loudly. + +**Implementation note:** Every proof binding must use the **concrete permission type**, not an inferred generic. Writing `let _proof = cap.cap_ref()` compiles but loses the type witness entirely — the compiler infers the type without enforcing it. The explicit `: Cap` annotation is the whole point. For `copy()`, which takes two capability parameters, both must be witnessed: + +```rust +pub fn copy( + from: impl AsRef, + to: impl AsRef, + read_cap: &impl Has, + write_cap: &impl Has, +) -> Result { + let _read_proof: Cap = read_cap.cap_ref(); + let _write_proof: Cap = write_cap.cap_ref(); + Ok(std::fs::copy(from, to)?) +} +``` + +This change is applied to **all 20 functions** across `capsec-std/src/{fs,net,env,process}.rs`. + +### Test changes + +| Test | Current | After | +|------|---------|-------| +| `sealed_has_no_external_impl` | Fails to compile | **Remove** — external impls are now allowed by design | +| `has_forgery_panic` | Fails to compile | **Remove** — same reason | +| `has_forgery_loop` | Fails to compile | **Remove** | +| `has_forgery_god_mode` | Fails to compile | **Remove** | +| `has_forgery_process_exit` | Fails to compile | **Remove** | +| `has_forgery_capsec_std_fs` | Fails to compile | **Remove** | +| `has_forgery_capsec_std_env` | Fails to compile | **Remove** | + +These tests verified the `Sealed

` supertrait, which is being intentionally removed. They should be replaced with a new test that documents the `Cap::new()` security boundary: + +```rust +// cap_new_is_private.rs (already exists, keep it) +// Verifies that Cap::new() is pub(crate) — the real security gate +``` + +And a new **runtime** test verifying the context pattern: + +```rust +#[test] +fn context_struct_satisfies_has() { + struct TestCtx { fs: Cap } + impl Has for TestCtx { + fn cap_ref(&self) -> Cap { self.fs.cap_ref() } + } + + let root = test_root(); + let ctx = TestCtx { fs: root.grant::() }; + // Passes: TestCtx satisfies Has + fn needs_fs(_: &impl Has) {} + needs_fs(&ctx); +} +``` + +### Permission trait stays sealed + +`Permission` remains sealed via its own `sealed::Sealed` supertrait. External crates still cannot invent new permission types. Only the `Has

` delegation is opened. + +--- + +## Before/After: Full Comparison + +### Before (current) + +```rust +use capsec::prelude::*; + +fn main() -> Result<(), Box> { + let root = capsec::root(); + let fs_read = root.grant::(); + let fs_write = root.grant::(); + let net = root.grant::(); + + let config = load_config("app.toml", &fs_read)?; + let result = process(&config); + save_result(&result, &fs_write)?; + send_report(&result, &net)?; + Ok(()) +} + +fn load_config(p: &str, c: &impl Has) -> Result { + capsec::fs::read_to_string(p, c) +} + +// 5 levels deep — must carry all 3 caps +fn deep_function( + data: &str, + fc: &impl Has, + wc: &impl Has, + nc: &impl Has, +) -> Result<(), CapSecError> { + let more = capsec::fs::read_to_string("extra.txt", fc)?; + capsec::fs::write("output.txt", &more, wc)?; + let mut s = capsec::net::tcp_connect("metrics:9090", nc)?; + Ok(()) +} +``` + +### After (with all three layers) + +```rust +use capsec::prelude::*; + +#[capsec::context] +struct Ctx { + read: FsRead, + write: FsWrite, + net: NetConnect, +} + +#[capsec::main] +fn main(root: CapRoot) -> Result<(), Box> { + let ctx = Ctx::new(&root); + let config = load_config("app.toml", &ctx)?; + let result = process(&config); + save_result(&result, &ctx)?; + send_report(&result, &ctx)?; + Ok(()) +} + +fn load_config(p: &str, c: &impl Has) -> Result { + capsec::fs::read_to_string(p, c) +} + +// 5 levels deep — ONE parameter +fn deep_function(ctx: &Ctx) -> Result<(), CapSecError> { + let more = capsec::fs::read_to_string("extra.txt", ctx)?; + capsec::fs::write("output.txt", &more, ctx)?; + let mut s = capsec::net::tcp_connect("metrics:9090", ctx)?; + Ok(()) +} +``` + +Lines of setup: **5 instead of 7.** Parameters threaded through call stack: **1 instead of 3.** And the gap widens as the app grows — a function touching 5 I/O categories goes from 5 cap parameters to 1 context reference. + +--- + +## Implementation Plan + +### Phase 1: Unseal `Has

` and harden wrappers (prerequisite for Layer 3) + +| Step | File | Change | +|------|------|--------| +| 1a | `capsec-core/src/has.rs` | Remove `sealed::Sealed

` supertrait from `Has

` | +| 1b | `capsec-core/src/has.rs` | Remove `mod sealed` block and all `impl Sealed

` | +| 1c | `capsec-core/src/has.rs` | Remove `#[allow(private_bounds)]` attribute | +| 1d | `capsec-core/src/has.rs` | Add `impl Has

for SendCap

` | +| 1e | `capsec-std/src/fs.rs` | Change all `let _ = cap.cap_ref()` to `let _proof: Cap

= cap.cap_ref()` | +| 1f | `capsec-std/src/net.rs` | Same `_proof` change | +| 1g | `capsec-std/src/env.rs` | Same `_proof` change | +| 1h | `capsec-std/src/process.rs` | Same `_proof` change | +| 1i | `capsec-tests/tests/compile_fail/` | Remove 7 forgery/sealing compile-fail tests + `.stderr` files | +| 1j | `capsec-tests/tests/type_system.rs` | Add runtime test for context-pattern delegation | +| 1k | `README.md` | Update Security Model section: note `Has

` is open for delegation, `Cap::new()` is the boundary | + +### Phase 2: Layer 1 — Convenience methods + +| Step | File | Change | +|------|------|--------| +| 2a | `capsec-core/src/root.rs` | Add named methods to `CapRoot` | +| 2b | `capsec-core/src/root.rs` | Add tests for each convenience method | + +### Phase 3: Layer 2 — `capsec::run()` and `#[capsec::main]` + +| Step | File | Change | +|------|------|--------| +| 3a | `capsec/src/lib.rs` | Add `pub fn run(f: impl FnOnce(CapRoot) -> T) -> T` | +| 3b | `capsec-macro/src/lib.rs` | Add `#[capsec::main]` proc macro (with async stacking-order detection) | +| 3c | `capsec/src/lib.rs` | Re-export `main` macro | +| 3d | `capsec-tests/` | Add test for `run()` and `#[capsec::main]` | +| 3e | `capsec/tests/compile_fail/` | Add test: `#[capsec::main]` above `#[tokio::main]` emits ordering error | + +### Phase 4: Layer 3 — `#[capsec::context]` + +| Step | File | Change | +|------|------|--------| +| 4a | `capsec-macro/src/lib.rs` | Add `#[capsec::context]` proc macro with field-type validation against known permission set | +| 4b | `capsec-macro/src/lib.rs` | Support `#[capsec::context(send)]` variant | +| 4c | `capsec/src/lib.rs` | Re-export `context` macro | +| 4d | `capsec-tests/` | Add tests: context satisfies Has

, send variant is Send+Sync | +| 4e | `capsec/tests/compile_fail/` | Add test: context with wrong permission type emits clear error at field span | + +### Phase 5: Upgrade `#[capsec::requires]` to emit assertions + +| Step | File | Change | +|------|------|--------| +| 5a | `capsec-macro/src/lib.rs` | Modify `#[requires]` to parse `on = param` and emit `const _: ()` trait-bound check | +| 5b | `capsec-macro/src/lib.rs` | Skip assertion for `impl Has

` params (already enforced by compiler) | +| 5c | `capsec-macro/src/lib.rs` | Emit `compile_error!` when concrete types present but `on` is missing | +| 5d | `capsec-macro/src/lib.rs` | Update `#[requires]` doc comments | +| 5e | `capsec-macro/README.md` | Update docs: `#[requires]` now validates, not just documents | +| 5f | `capsec/tests/compile_fail/` | Add test: `#[requires(fs::read, on = ctx)]` where ctx type lacks `Has` fails | +| 5g | `capsec/tests/compile_fail/` | Add test: `#[requires(fs::read)]` with concrete params and no `on` emits error | +| 5h | `capsec-tests/` | Add test: `#[requires(fs::read, on = ctx)]` on fn accepting context struct compiles | +| 5i | `capsec-tests/` | Add test: `#[requires(fs::read)]` on fn with `impl Has` compiles (no `on` needed) | + +### Phase 6: Examples and docs + +| Step | File | Change | +|------|------|--------| +| 6a | `crates/capsec/examples/type_enforcement.rs` | Rewrite using `#[capsec::main]` + convenience methods | +| 6b | `crates/capsec/examples/incremental_migration.rs` | Rewrite using `#[capsec::main]` | +| 6c | `crates/capsec/examples/context_pattern.rs` | **New** — full example of `#[capsec::context]` with sub-contexts | +| 6d | `crates/capsec/examples/async_context.rs` | **New** — `#[capsec::context(send)]` with tokio | +| 6e | `README.md` | Update "After capsec" section to show context pattern | +| 6f | `capsec-macro/README.md` | Document `#[capsec::main]`, `#[capsec::context]`, and `#[requires]` assertion behavior | +| 6g | `capsec/README.md` | Update facade README with new macros | +| 6h | `CONTRIBUTING.md` | Add section on the context pattern | + +--- + +## Migration & Compatibility + +All changes are **additive**. No existing API is removed or changed: + +| Existing API | Status | +|---|---| +| `root.grant::

()` | Unchanged — still works | +| `Has

` trait bounds on functions | Unchanged — still works | +| `Cap

`, `SendCap

` | Unchanged | +| `capsec::fs::*`, `capsec::net::*`, etc. | Unchanged — accept `&impl Has

` which context structs satisfy | +| `Attenuated`, `DirScope`, `HostScope` | Unchanged | +| `#[capsec::requires]`, `#[capsec::deny]` | **Changed** — `#[requires]` now emits compile-time assertions (see below) | + +The breaking changes are: + +1. **Removal of `Has

` sealing.** Code that relied on the compile-time guarantee that `Has

` can only be implemented within capsec-core will find that guarantee removed. This is a security-model documentation change, not an API change. The `Cap::new()` boundary is unchanged. + +2. **`#[requires]` now validates.** Functions annotated with `#[requires(fs::read)]` will now fail to compile if no parameter implements `Has`. Previously this was documentation-only. Any function where the `#[requires]` annotation didn't match the actual parameters will break. This is intentional — the annotation was lying. + +--- + +## Layer 4: `#[capsec::requires]` Becomes Assertive + +### The missing link + +Currently `#[capsec::requires(fs::read)]` emits only a `#[doc]` attribute. It documents intent but validates nothing — a function annotated with `#[requires(fs::read)]` can have zero capability parameters and the code compiles fine. This makes `#[requires]` a decorative comment. + +With `#[capsec::context]`, the situation becomes actively misleading. A function accepting `ctx: &AppCtx` can be annotated with `#[requires(fs::read)]` even if `AppCtx` doesn't implement `Has`. Nothing catches the mismatch. + +### The fix + +`#[requires]` should emit a compile-time trait-bound assertion for each declared permission. The behavior depends on the function signature: + +**Case 1: `impl Has

` bounds present.** The compiler already enforces the trait bound. The macro emits only the `#[doc]` attribute — no additional assertion needed. This is the existing capsec-std pattern and requires no `on` keyword. + +```rust +// impl bounds — compiler already enforces, no assertion needed +#[capsec::requires(fs::read)] +fn load(path: &str, cap: &impl Has) -> Result { ... } +``` + +**Case 2: Concrete context type — user specifies `on = param_name`.** Proc macros operate on the AST via `syn`, before type resolution. The macro cannot determine which parameter implements `Has

` — it sees token trees, not resolved types. Attempting to infer the "right" parameter by heuristic (e.g., "first non-primitive type") is brittle and produces confusing errors when wrong. + +Instead, the user explicitly names the capability parameter using `on`: + +```rust +#[capsec::requires(fs::read, net::connect, on = ctx)] +fn sync_data(config: &Config, ctx: &AppCtx) -> Result<()> { + // ... +} +``` + +The macro parses `on = ctx`, finds the parameter named `ctx` in the function signature, extracts its type (`AppCtx`), and emits a `const` assertion block: + +```rust +#[doc = "capsec::requires(FsRead, NetConnect)"] +fn sync_data(config: &Config, ctx: &AppCtx) -> Result<()> { + const _: () = { + fn _assert_has_fs_read>() {} + fn _assert_has_net_connect>() {} + fn _check() { + _assert_has_fs_read::(); + _assert_has_net_connect::(); + } + }; + + // ... original body +} +``` + +If `AppCtx` doesn't implement `Has`, the compile fails with a clear trait-bound error — the same kind of error capsec already produces for wrong-cap-type mistakes. + +**Case 3: No `impl` bounds and no `on` keyword.** The macro emits a `compile_error!`: + +``` +error: #[capsec::requires] on a function with concrete parameter types requires + `on = ` to identify the capability parameter. + Example: #[capsec::requires(fs::read, on = ctx)] +``` + +This is zero heuristics, zero ambiguity. The `on` keyword is self-documenting and matches the existing capsec convention where the capability parameter is explicitly named in every function signature. + +### What this completes + +The three macro layers now form a closed system: + +| Macro | Role | +|-------|------| +| `#[capsec::context]` | **Generates** `Has

` impls on a context struct | +| `#[capsec::requires]` | **Validates** that a function's parameters satisfy the declared permissions | +| `#[capsec::deny]` | **Flags** (via audit tool) functions that should have zero I/O | + +The context macro generates the impls. The requires macro validates they exist. Without the requires upgrade, there's a gap between declaration and enforcement that the context pattern makes wider. + +--- + +## Decisions (Resolved) + +1. **Default `send` or `local`:** Default to `Cap

` (`!Send`). Matches capsec's explicit opt-in philosophy. The async world is the majority case for web services but the minority case for CLI tools, data pipelines, and the kind of code most likely to adopt capsec first. Use `#[capsec::context(send)]` for async. Document the async pattern prominently. + +2. **`Has

` for `SendCap

`:** Yes. Add `impl Has

for SendCap

`. Without it, the send context macro generates `.as_cap()` calls inside `cap_ref()` — unnecessary indirection. Direct `Has

` on `SendCap

` is cleaner. + +3. **Field visibility:** Private fields + generated accessor methods. Public fields would let users extract a `Cap

` and pass it around independently of the context, which isn't wrong but defeats the "single context reference" ergonomic goal. Users who need a raw cap can call `ctx.cap_ref()` via the `Has

` impl. + +4. **Context inheritance / composition:** Not in v1. Flat structs with permission fields only. Composition is a "nice problem to have" after people are actually using the context pattern. Can be added later without breaking changes. + +5. **Macro name:** `#[capsec::context]`. It's what the pattern is called in the DI literature. `cap_bundle` sounds like a marketing term. `grants` is ambiguous with `CapRoot::grant()`. From 828a55fdc78a61f06b4559a2c4d124e8d056422e Mon Sep 17 00:00:00 2001 From: bordumb Date: Wed, 18 Mar 2026 22:16:12 +0000 Subject: [PATCH 2/3] docs: update readme with source install --- README.md | 3 +++ crates/capsec/README.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/README.md b/README.md index 2d3665a..95b79db 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ Scans Rust source for ambient authority (filesystem, network, env, process) and ```bash cargo install cargo-capsec + +# Or from source: +cargo install --path crates/cargo-capsec ``` ### Run diff --git a/crates/capsec/README.md b/crates/capsec/README.md index 310d18d..6dec085 100644 --- a/crates/capsec/README.md +++ b/crates/capsec/README.md @@ -8,6 +8,9 @@ This is the facade crate — it re-exports everything from `capsec-core`, `capse ```bash cargo add capsec + +# Or from source: +cargo install --path crates/capsec ``` ## Quick start From a1c36db318344cc7942a115fd7a7aedb3f47d824 Mon Sep 17 00:00:00 2001 From: bordumb Date: Wed, 18 Mar 2026 22:18:23 +0000 Subject: [PATCH 3/3] fix: remove .md file --- ergonomics_spec.md | 825 --------------------------------------------- 1 file changed, 825 deletions(-) delete mode 100644 ergonomics_spec.md diff --git a/ergonomics_spec.md b/ergonomics_spec.md deleted file mode 100644 index 90b20fa..0000000 --- a/ergonomics_spec.md +++ /dev/null @@ -1,825 +0,0 @@ -# Ergonomics Spec: Making capsec Painless - -**Status:** Draft -**Authors:** capsec maintainers -**Date:** 2026-03-18 - ---- - -## Problem - -capsec's type-system enforcement is sound. Adopting it is not painless. There are three layers of friction, each compounding the one before it: - -### Friction 1: Entry-point ceremony - -Creating capabilities requires turbofish syntax and multi-step setup: - -```rust -fn main() { - let root = capsec::root(); // step 1: get root - let fs_cap = root.grant::(); // step 2: turbofish grant - let net_cap = root.grant::(); // step 3: another turbofish - let config = load_config("app.toml", &fs_cap); -} -``` - -`grant::()` is not discoverable via IDE autocomplete, not obvious to Rust newcomers, and requires importing each permission type by name. - -### Friction 2: The coloring problem - -Every function between `main()` and the leaf I/O call must thread capability parameters through its signature. This is the "function coloring" burden: - -```rust -fn application_logic( - config: &str, - fs_cap: &impl Has, // must carry this... - write_cap: &impl Has, // ...and this... - net_cap: &impl Has, // ...and this -) -> Result<(), CapSecError> { - let data = load_data("input.csv", fs_cap)?; - let result = transform(&data); - save_output(&result, write_cap)?; - notify_service(&result, net_cap)?; - Ok(()) -} -``` - -A function 5 levels deep in the call stack needs capability parameters it doesn't use directly, just to forward them. This is the same pain as manual dependency injection without a container. It scales linearly with the number of permission categories your app touches. - -### Friction 3: Pattern confusion - -New users face three adoption patterns (audit-only, type enforcement, incremental migration) and no guidance on which one to pick. The examples show the patterns but don't reduce the ceremony of any of them. - ---- - -## Design Principles - -1. **Zero ceremony for the common case.** A small app with 2-3 I/O categories should need ~5 lines of capsec setup, not 15. -2. **Capability threading should be invisible.** A function in the middle of the call stack should accept a single context reference, not N separate capability parameters. -3. **Leaf functions stay generic.** Functions that do actual I/O should still accept `&impl Has

`, so they work with raw caps, context structs, and any future capability holder. -4. **No security regression.** Convenience must not weaken the type-system guarantees. Unsealing `Has

` is safe because `Cap::new()` remains `pub(crate)` — there is no safe-code path to forging a capability token (see Security section). -5. **Additive, not breaking.** Every change is backwards-compatible. `root.grant::()` continues to work. - ---- - -## Proposal: Three Layers - -Each layer is independently useful. Together they eliminate all three friction sources. - -### Layer 1: Convenience methods on `CapRoot` - -**Solves:** Friction 1 (turbofish, discoverability) - -Add named methods directly on `CapRoot` for every built-in permission: - -```rust -impl CapRoot { - pub fn fs_read(&self) -> Cap { self.grant() } - pub fn fs_write(&self) -> Cap { self.grant() } - pub fn fs_all(&self) -> Cap { self.grant() } - pub fn net_connect(&self) -> Cap { self.grant() } - pub fn net_bind(&self) -> Cap { self.grant() } - pub fn net_all(&self) -> Cap { self.grant() } - pub fn env_read(&self) -> Cap { self.grant() } - pub fn env_write(&self) -> Cap { self.grant() } - pub fn spawn(&self) -> Cap { self.grant() } - pub fn ambient(&self) -> Cap { self.grant() } -} -``` - -**Before:** -```rust -let fs_cap = root.grant::(); -``` - -**After:** -```rust -let fs_cap = root.fs_read(); -``` - -Benefits: -- IDE autocomplete shows all available permissions after `root.` -- No turbofish syntax for the common case -- Self-documenting: `root.fs_read()` reads as intent -- `grant::

()` still works for tuples and advanced use cases - -**Files changed:** `capsec-core/src/root.rs` -**Effort:** S - -### Layer 2: `capsec::run()` and `#[capsec::main]` - -**Solves:** Friction 1 (boilerplate root creation) - -#### `capsec::run()` - -A functional entry point that handles root creation: - -```rust -// In capsec/src/lib.rs -pub fn run(f: impl FnOnce(CapRoot) -> T) -> T { - let root = root(); - f(root) -} -``` - -Usage: -```rust -fn main() { - capsec::run(|root| { - let fs = root.fs_read(); - let config = load_config("app.toml", &fs).unwrap(); - }); -} -``` - -#### `#[capsec::main]` - -Attribute macro that injects root creation: - -```rust -#[capsec::main] -fn main(root: CapRoot) -> Result<(), Box> { - let ctx = AppCtx::new(&root); - start_app(&ctx)?; - Ok(()) -} -``` - -Expands to: - -```rust -fn main() -> Result<(), Box> { - let root = capsec::root(); - let ctx = AppCtx::new(&root); - start_app(&ctx)?; - Ok(()) -} -``` - -The macro: -1. Extracts the first parameter (must be `CapRoot` or `capsec::CapRoot`) -2. Removes it from the function signature -3. Prepends `let {param_name} = capsec::root();` to the body -4. Preserves the return type and all other attributes - -#### Stacking with `#[tokio::main]` - -Rust proc macro attributes execute **bottom-up**. When combining `#[capsec::main]` with `#[tokio::main]`, the order matters: - -```rust -// CORRECT — #[capsec::main] runs first (bottom-up), removes the root param, -// then #[tokio::main] wraps the result in a runtime block. -#[tokio::main] -#[capsec::main] -async fn main(root: CapRoot) { ... } -``` - -```rust -// WRONG — #[tokio::main] runs first, transforms the async fn into a -// synchronous fn with a runtime block. #[capsec::main] then sees a -// parameterless main() and fails or produces wrong code. -#[capsec::main] -#[tokio::main] -async fn main(root: CapRoot) { ... } -``` - -The macro should detect `async fn` with zero parameters (indicating `#[tokio::main]` already ran) and emit a clear error: - -``` -error: #[capsec::main] must be placed below #[tokio::main], not above it. - Proc macro attributes execute bottom-up. - - Correct: - #[tokio::main] - #[capsec::main] - async fn main(root: CapRoot) { ... } -``` - -**Files changed:** `capsec-macro/src/lib.rs`, `capsec/src/lib.rs` -**Effort:** M - -### Layer 3: `#[capsec::context]` — the capability context macro - -**Solves:** Friction 2 (the coloring problem) - -This is the primary ergonomic improvement. A user defines a plain struct listing the permissions they need. The macro generates: -- A constructor that grants all capabilities from a `CapRoot` -- `Has

` implementations for each permission, so the struct can be passed directly to any capsec-gated function - -#### User writes: - -```rust -#[capsec::context] -pub struct AppCtx { - fs: FsRead, - write: FsWrite, - net: NetConnect, -} -``` - -#### Macro generates: - -```rust -pub struct AppCtx { - fs: Cap, - write: Cap, - net: Cap, -} - -impl AppCtx { - pub fn new(root: &CapRoot) -> Self { - Self { - fs: root.grant::(), - write: root.grant::(), - net: root.grant::(), - } - } -} - -impl Has for AppCtx { - fn cap_ref(&self) -> Cap { - self.fs.cap_ref() - } -} - -impl Has for AppCtx { - fn cap_ref(&self) -> Cap { - self.write.cap_ref() - } -} - -impl Has for AppCtx { - fn cap_ref(&self) -> Cap { - self.net.cap_ref() - } -} -``` - -#### The experience: - -```rust -#[capsec::context] -pub struct AppCtx { - fs: FsRead, - write: FsWrite, - net: NetConnect, -} - -#[capsec::main] -fn main(root: CapRoot) -> Result<(), Box> { - let ctx = AppCtx::new(&root); - application_logic(&ctx)?; - Ok(()) -} - -// ONE parameter, not three. Works at any call depth. -fn application_logic(ctx: &AppCtx) -> Result<(), CapSecError> { - let data = load_data("input.csv", ctx)?; // ctx satisfies Has - let result = transform(&data); - save_output(&result, ctx)?; // ctx satisfies Has - notify_service(&result, ctx)?; // ctx satisfies Has - Ok(()) -} - -// Leaf functions still use &impl Has

— they work with raw caps AND context structs. -fn load_data(path: &str, cap: &impl Has) -> Result { - capsec::fs::read_to_string(path, cap) -} -``` - -#### Async / threaded apps: `#[capsec::context(send)]` - -`Cap

` is `!Send + !Sync` by default. For apps using tokio, actix, or threads, the macro supports a `send` option that uses `SendCap

` instead: - -```rust -#[capsec::context(send)] -pub struct AppCtx { - fs: FsRead, - net: NetConnect, -} -``` - -Generates: - -```rust -pub struct AppCtx { - fs: SendCap, - net: SendCap, -} - -impl AppCtx { - pub fn new(root: &CapRoot) -> Self { - Self { - fs: root.grant::().make_send(), - net: root.grant::().make_send(), - } - } -} - -impl Has for AppCtx { - fn cap_ref(&self) -> Cap { - self.fs.as_cap() - } -} - -// ... etc -``` - -Now `AppCtx` is `Send + Sync` and can be wrapped in `Arc` for shared state: - -```rust -#[tokio::main] -#[capsec::main] -async fn main(root: CapRoot) { - let ctx = Arc::new(AppCtx::new(&root)); - - let ctx_clone = ctx.clone(); - tokio::spawn(async move { - handle_request(&*ctx_clone).await; - }); -} -``` - -#### Sub-contexts for least privilege - -The context pattern does NOT encourage over-granting. Users can define narrow contexts for different subsystems: - -```rust -#[capsec::context] -struct IngestCtx { - fs: FsRead, // can read, not write -} - -#[capsec::context] -struct OutputCtx { - fs: FsWrite, // can write, not read - net: NetConnect, // can connect -} - -fn ingest(ctx: &IngestCtx) -> Result { ... } -fn publish(ctx: &OutputCtx) -> Result<(), CapSecError> { ... } -``` - -The compiler enforces the boundary: `ingest()` cannot write files, `publish()` cannot read them. Each subsystem gets exactly the authority it needs. - -#### Interaction with scoping/attenuation - -The context is for *threading capabilities through call stacks*. It is not a replacement for `Attenuated` scoping. When you need path-restricted or host-restricted capabilities, extract the raw cap: - -```rust -fn scoped_read(ctx: &AppCtx) -> Result { - let raw_cap: Cap = ctx.cap_ref(); - let scoped = raw_cap.attenuate(DirScope::new("/var/data")?); - scoped.check("/var/data/input.csv")?; - capsec::fs::read_to_string("/var/data/input.csv", &raw_cap) -} -``` - -This is an intentional design boundary. Context bundles authority; scoping restricts it. - -#### Compile-time validation of field types - -The macro must validate that every field type is a known permission type. `Permission` is sealed — the set of valid types is fixed and exhaustive (`FsRead`, `FsWrite`, `FsAll`, `NetConnect`, `NetBind`, `NetAll`, `EnvRead`, `EnvWrite`, `Spawn`, `Ambient`). The macro checks each field's type path against this set at expansion time. - -If a user writes a non-permission type: - -```rust -#[capsec::context] -struct Bad { - x: String, -} -``` - -The macro emits a clear `compile_error!` at the field's span: - -``` -error: field `x` has type `String`, which is not a capsec permission type. - Expected one of: FsRead, FsWrite, FsAll, NetConnect, NetBind, - NetAll, EnvRead, EnvWrite, Spawn, Ambient - - --> src/main.rs:4:5 - | -4 | x: String, - | ^^^^^^^^^ -``` - -Without this validation, the generated `root.grant::()` would fail with a generic `String: Permission is not satisfied` error pointing at macro-generated code — confusing and unhelpful. The validation catches the mistake at the user's source location with an actionable message. - -**Tuple permission types are not supported in context structs.** While capsec supports `Cap<(FsRead, NetConnect)>` as a tuple permission, the context macro accepts only single permission types per field. Users who want multiple permissions use separate fields — this is clearer and produces one-to-one `Has

` impls per field: - -```rust -// Correct — one permission per field -#[capsec::context] -struct Ctx { - fs: FsRead, - net: NetConnect, -} - -// Not supported — tuple field type -#[capsec::context] -struct Ctx { - combo: (FsRead, NetConnect), // compile error -} -``` - -Tuple syntax would require the macro to destructure the type, generate `Cap<(A, B)>`, and emit `Has

` impls for both inner types from a single field. This is more complex to implement and harder to read. Flat fields are simpler, and the generated code is trivially auditable. Tuple support can be added in a future version if there's demand. - -**Files changed:** `capsec-macro/src/lib.rs`, `capsec-core/src/has.rs` -**Effort:** L - ---- - -## Required Change: Unsealing `Has

` - -### What and why - -`Has

` currently has a sealed supertrait (`capsec_core::has::sealed::Sealed

`). This prevents any type outside `capsec-core` from implementing `Has

`. The `#[capsec::context]` macro generates `impl Has

for UserStruct` in user code, which the sealed trait blocks. - -**The seal must be removed** for the context macro to work. This means changing: - -```rust -// Before -pub trait Has: sealed::Sealed

{ - fn cap_ref(&self) -> Cap

; -} -``` - -to: - -```rust -// After -pub trait Has { - fn cap_ref(&self) -> Cap

; -} -``` - -### Security analysis - -The `Has

` trait requires implementors to return a `Cap

` from `cap_ref()`. `Cap::new()` is `pub(crate)` — only code inside `capsec-core` can construct a capability token. This is the security boundary. - -**Why unsealing `Has

` is safe:** - -With `Has

` unsealed, external code can `impl Has for MyContext`. To satisfy the trait, the implementor must return a `Cap` from `cap_ref()`. There are exactly two ways to obtain a `Cap

`: - -1. **Legitimately, via `CapRoot::grant()`** — the implementor already holds the authority and is delegating it. This is the context pattern working as designed. -2. **Via `unsafe` code** — `transmute`, `zeroed`, `MaybeUninit`, or raw pointer tricks can forge a `Cap

`. All of these require an `unsafe` block, which places them outside capsec's safe-Rust threat model. The audit tool (`cargo capsec audit`) flags `unsafe` blocks and FFI. - -There is no safe-code path to constructing a `Cap

` outside of `capsec-core`. Therefore, there is no safe-code path to implementing `Has

` maliciously. The sealed trait was defense-in-depth on top of this already-airtight boundary. Removing it enables the context pattern without opening any new forgery vector in safe Rust. - -**The divergence case:** - -With `Has

` unsealed, someone could write a diverging implementation: - -```rust -struct Fake; -impl Has for Fake { - fn cap_ref(&self) -> Cap { panic!("forgery") } -} -``` - -This compiles, but it's not a useful attack. The `cap_ref()` call panics (or loops) before any I/O executes. And crucially, someone who can modify the codebase to add this impl could just call `std::fs::read()` directly — it's not an escalation of authority. Nevertheless, we harden the wrappers as belt-and-suspenders (see below). - -### Mitigation: Type-witnessed proof in capsec-std wrappers - -As defense-in-depth against diverging `cap_ref()` implementations, capsec-std wrappers should use a type-annotated binding that forces `cap_ref()` to actually return before any I/O executes. - -Currently, capsec-std wrappers call `cap_ref()` and discard the result: - -```rust -// Current — called but result discarded -pub fn read(path: impl AsRef, cap: &impl Has) -> Result, CapSecError> { - let _ = cap.cap_ref(); - Ok(std::fs::read(path)?) -} -``` - -Change every wrapper to use a type-annotated binding: - -```rust -// Proposed — type witness forces cap_ref() to actually return -pub fn read(path: impl AsRef, cap: &impl Has) -> Result, CapSecError> { - let _proof: Cap = cap.cap_ref(); - Ok(std::fs::read(path)?) -} -``` - -The `_proof: Cap` binding is zero-cost (ZST), but it forces `cap_ref()` to return a value of the correct type. A diverging `Has

` impl (`panic!()` or `loop {}`) fires *before* the I/O call, never after. This is a belt-and-suspenders guarantee: the primary security gate is `Cap::new()` being `pub(crate)`, and this ensures that even non-useful divergence attacks are caught loudly. - -**Implementation note:** Every proof binding must use the **concrete permission type**, not an inferred generic. Writing `let _proof = cap.cap_ref()` compiles but loses the type witness entirely — the compiler infers the type without enforcing it. The explicit `: Cap` annotation is the whole point. For `copy()`, which takes two capability parameters, both must be witnessed: - -```rust -pub fn copy( - from: impl AsRef, - to: impl AsRef, - read_cap: &impl Has, - write_cap: &impl Has, -) -> Result { - let _read_proof: Cap = read_cap.cap_ref(); - let _write_proof: Cap = write_cap.cap_ref(); - Ok(std::fs::copy(from, to)?) -} -``` - -This change is applied to **all 20 functions** across `capsec-std/src/{fs,net,env,process}.rs`. - -### Test changes - -| Test | Current | After | -|------|---------|-------| -| `sealed_has_no_external_impl` | Fails to compile | **Remove** — external impls are now allowed by design | -| `has_forgery_panic` | Fails to compile | **Remove** — same reason | -| `has_forgery_loop` | Fails to compile | **Remove** | -| `has_forgery_god_mode` | Fails to compile | **Remove** | -| `has_forgery_process_exit` | Fails to compile | **Remove** | -| `has_forgery_capsec_std_fs` | Fails to compile | **Remove** | -| `has_forgery_capsec_std_env` | Fails to compile | **Remove** | - -These tests verified the `Sealed

` supertrait, which is being intentionally removed. They should be replaced with a new test that documents the `Cap::new()` security boundary: - -```rust -// cap_new_is_private.rs (already exists, keep it) -// Verifies that Cap::new() is pub(crate) — the real security gate -``` - -And a new **runtime** test verifying the context pattern: - -```rust -#[test] -fn context_struct_satisfies_has() { - struct TestCtx { fs: Cap } - impl Has for TestCtx { - fn cap_ref(&self) -> Cap { self.fs.cap_ref() } - } - - let root = test_root(); - let ctx = TestCtx { fs: root.grant::() }; - // Passes: TestCtx satisfies Has - fn needs_fs(_: &impl Has) {} - needs_fs(&ctx); -} -``` - -### Permission trait stays sealed - -`Permission` remains sealed via its own `sealed::Sealed` supertrait. External crates still cannot invent new permission types. Only the `Has

` delegation is opened. - ---- - -## Before/After: Full Comparison - -### Before (current) - -```rust -use capsec::prelude::*; - -fn main() -> Result<(), Box> { - let root = capsec::root(); - let fs_read = root.grant::(); - let fs_write = root.grant::(); - let net = root.grant::(); - - let config = load_config("app.toml", &fs_read)?; - let result = process(&config); - save_result(&result, &fs_write)?; - send_report(&result, &net)?; - Ok(()) -} - -fn load_config(p: &str, c: &impl Has) -> Result { - capsec::fs::read_to_string(p, c) -} - -// 5 levels deep — must carry all 3 caps -fn deep_function( - data: &str, - fc: &impl Has, - wc: &impl Has, - nc: &impl Has, -) -> Result<(), CapSecError> { - let more = capsec::fs::read_to_string("extra.txt", fc)?; - capsec::fs::write("output.txt", &more, wc)?; - let mut s = capsec::net::tcp_connect("metrics:9090", nc)?; - Ok(()) -} -``` - -### After (with all three layers) - -```rust -use capsec::prelude::*; - -#[capsec::context] -struct Ctx { - read: FsRead, - write: FsWrite, - net: NetConnect, -} - -#[capsec::main] -fn main(root: CapRoot) -> Result<(), Box> { - let ctx = Ctx::new(&root); - let config = load_config("app.toml", &ctx)?; - let result = process(&config); - save_result(&result, &ctx)?; - send_report(&result, &ctx)?; - Ok(()) -} - -fn load_config(p: &str, c: &impl Has) -> Result { - capsec::fs::read_to_string(p, c) -} - -// 5 levels deep — ONE parameter -fn deep_function(ctx: &Ctx) -> Result<(), CapSecError> { - let more = capsec::fs::read_to_string("extra.txt", ctx)?; - capsec::fs::write("output.txt", &more, ctx)?; - let mut s = capsec::net::tcp_connect("metrics:9090", ctx)?; - Ok(()) -} -``` - -Lines of setup: **5 instead of 7.** Parameters threaded through call stack: **1 instead of 3.** And the gap widens as the app grows — a function touching 5 I/O categories goes from 5 cap parameters to 1 context reference. - ---- - -## Implementation Plan - -### Phase 1: Unseal `Has

` and harden wrappers (prerequisite for Layer 3) - -| Step | File | Change | -|------|------|--------| -| 1a | `capsec-core/src/has.rs` | Remove `sealed::Sealed

` supertrait from `Has

` | -| 1b | `capsec-core/src/has.rs` | Remove `mod sealed` block and all `impl Sealed

` | -| 1c | `capsec-core/src/has.rs` | Remove `#[allow(private_bounds)]` attribute | -| 1d | `capsec-core/src/has.rs` | Add `impl Has

for SendCap

` | -| 1e | `capsec-std/src/fs.rs` | Change all `let _ = cap.cap_ref()` to `let _proof: Cap

= cap.cap_ref()` | -| 1f | `capsec-std/src/net.rs` | Same `_proof` change | -| 1g | `capsec-std/src/env.rs` | Same `_proof` change | -| 1h | `capsec-std/src/process.rs` | Same `_proof` change | -| 1i | `capsec-tests/tests/compile_fail/` | Remove 7 forgery/sealing compile-fail tests + `.stderr` files | -| 1j | `capsec-tests/tests/type_system.rs` | Add runtime test for context-pattern delegation | -| 1k | `README.md` | Update Security Model section: note `Has

` is open for delegation, `Cap::new()` is the boundary | - -### Phase 2: Layer 1 — Convenience methods - -| Step | File | Change | -|------|------|--------| -| 2a | `capsec-core/src/root.rs` | Add named methods to `CapRoot` | -| 2b | `capsec-core/src/root.rs` | Add tests for each convenience method | - -### Phase 3: Layer 2 — `capsec::run()` and `#[capsec::main]` - -| Step | File | Change | -|------|------|--------| -| 3a | `capsec/src/lib.rs` | Add `pub fn run(f: impl FnOnce(CapRoot) -> T) -> T` | -| 3b | `capsec-macro/src/lib.rs` | Add `#[capsec::main]` proc macro (with async stacking-order detection) | -| 3c | `capsec/src/lib.rs` | Re-export `main` macro | -| 3d | `capsec-tests/` | Add test for `run()` and `#[capsec::main]` | -| 3e | `capsec/tests/compile_fail/` | Add test: `#[capsec::main]` above `#[tokio::main]` emits ordering error | - -### Phase 4: Layer 3 — `#[capsec::context]` - -| Step | File | Change | -|------|------|--------| -| 4a | `capsec-macro/src/lib.rs` | Add `#[capsec::context]` proc macro with field-type validation against known permission set | -| 4b | `capsec-macro/src/lib.rs` | Support `#[capsec::context(send)]` variant | -| 4c | `capsec/src/lib.rs` | Re-export `context` macro | -| 4d | `capsec-tests/` | Add tests: context satisfies Has

, send variant is Send+Sync | -| 4e | `capsec/tests/compile_fail/` | Add test: context with wrong permission type emits clear error at field span | - -### Phase 5: Upgrade `#[capsec::requires]` to emit assertions - -| Step | File | Change | -|------|------|--------| -| 5a | `capsec-macro/src/lib.rs` | Modify `#[requires]` to parse `on = param` and emit `const _: ()` trait-bound check | -| 5b | `capsec-macro/src/lib.rs` | Skip assertion for `impl Has

` params (already enforced by compiler) | -| 5c | `capsec-macro/src/lib.rs` | Emit `compile_error!` when concrete types present but `on` is missing | -| 5d | `capsec-macro/src/lib.rs` | Update `#[requires]` doc comments | -| 5e | `capsec-macro/README.md` | Update docs: `#[requires]` now validates, not just documents | -| 5f | `capsec/tests/compile_fail/` | Add test: `#[requires(fs::read, on = ctx)]` where ctx type lacks `Has` fails | -| 5g | `capsec/tests/compile_fail/` | Add test: `#[requires(fs::read)]` with concrete params and no `on` emits error | -| 5h | `capsec-tests/` | Add test: `#[requires(fs::read, on = ctx)]` on fn accepting context struct compiles | -| 5i | `capsec-tests/` | Add test: `#[requires(fs::read)]` on fn with `impl Has` compiles (no `on` needed) | - -### Phase 6: Examples and docs - -| Step | File | Change | -|------|------|--------| -| 6a | `crates/capsec/examples/type_enforcement.rs` | Rewrite using `#[capsec::main]` + convenience methods | -| 6b | `crates/capsec/examples/incremental_migration.rs` | Rewrite using `#[capsec::main]` | -| 6c | `crates/capsec/examples/context_pattern.rs` | **New** — full example of `#[capsec::context]` with sub-contexts | -| 6d | `crates/capsec/examples/async_context.rs` | **New** — `#[capsec::context(send)]` with tokio | -| 6e | `README.md` | Update "After capsec" section to show context pattern | -| 6f | `capsec-macro/README.md` | Document `#[capsec::main]`, `#[capsec::context]`, and `#[requires]` assertion behavior | -| 6g | `capsec/README.md` | Update facade README with new macros | -| 6h | `CONTRIBUTING.md` | Add section on the context pattern | - ---- - -## Migration & Compatibility - -All changes are **additive**. No existing API is removed or changed: - -| Existing API | Status | -|---|---| -| `root.grant::

()` | Unchanged — still works | -| `Has

` trait bounds on functions | Unchanged — still works | -| `Cap

`, `SendCap

` | Unchanged | -| `capsec::fs::*`, `capsec::net::*`, etc. | Unchanged — accept `&impl Has

` which context structs satisfy | -| `Attenuated`, `DirScope`, `HostScope` | Unchanged | -| `#[capsec::requires]`, `#[capsec::deny]` | **Changed** — `#[requires]` now emits compile-time assertions (see below) | - -The breaking changes are: - -1. **Removal of `Has

` sealing.** Code that relied on the compile-time guarantee that `Has

` can only be implemented within capsec-core will find that guarantee removed. This is a security-model documentation change, not an API change. The `Cap::new()` boundary is unchanged. - -2. **`#[requires]` now validates.** Functions annotated with `#[requires(fs::read)]` will now fail to compile if no parameter implements `Has`. Previously this was documentation-only. Any function where the `#[requires]` annotation didn't match the actual parameters will break. This is intentional — the annotation was lying. - ---- - -## Layer 4: `#[capsec::requires]` Becomes Assertive - -### The missing link - -Currently `#[capsec::requires(fs::read)]` emits only a `#[doc]` attribute. It documents intent but validates nothing — a function annotated with `#[requires(fs::read)]` can have zero capability parameters and the code compiles fine. This makes `#[requires]` a decorative comment. - -With `#[capsec::context]`, the situation becomes actively misleading. A function accepting `ctx: &AppCtx` can be annotated with `#[requires(fs::read)]` even if `AppCtx` doesn't implement `Has`. Nothing catches the mismatch. - -### The fix - -`#[requires]` should emit a compile-time trait-bound assertion for each declared permission. The behavior depends on the function signature: - -**Case 1: `impl Has

` bounds present.** The compiler already enforces the trait bound. The macro emits only the `#[doc]` attribute — no additional assertion needed. This is the existing capsec-std pattern and requires no `on` keyword. - -```rust -// impl bounds — compiler already enforces, no assertion needed -#[capsec::requires(fs::read)] -fn load(path: &str, cap: &impl Has) -> Result { ... } -``` - -**Case 2: Concrete context type — user specifies `on = param_name`.** Proc macros operate on the AST via `syn`, before type resolution. The macro cannot determine which parameter implements `Has

` — it sees token trees, not resolved types. Attempting to infer the "right" parameter by heuristic (e.g., "first non-primitive type") is brittle and produces confusing errors when wrong. - -Instead, the user explicitly names the capability parameter using `on`: - -```rust -#[capsec::requires(fs::read, net::connect, on = ctx)] -fn sync_data(config: &Config, ctx: &AppCtx) -> Result<()> { - // ... -} -``` - -The macro parses `on = ctx`, finds the parameter named `ctx` in the function signature, extracts its type (`AppCtx`), and emits a `const` assertion block: - -```rust -#[doc = "capsec::requires(FsRead, NetConnect)"] -fn sync_data(config: &Config, ctx: &AppCtx) -> Result<()> { - const _: () = { - fn _assert_has_fs_read>() {} - fn _assert_has_net_connect>() {} - fn _check() { - _assert_has_fs_read::(); - _assert_has_net_connect::(); - } - }; - - // ... original body -} -``` - -If `AppCtx` doesn't implement `Has`, the compile fails with a clear trait-bound error — the same kind of error capsec already produces for wrong-cap-type mistakes. - -**Case 3: No `impl` bounds and no `on` keyword.** The macro emits a `compile_error!`: - -``` -error: #[capsec::requires] on a function with concrete parameter types requires - `on = ` to identify the capability parameter. - Example: #[capsec::requires(fs::read, on = ctx)] -``` - -This is zero heuristics, zero ambiguity. The `on` keyword is self-documenting and matches the existing capsec convention where the capability parameter is explicitly named in every function signature. - -### What this completes - -The three macro layers now form a closed system: - -| Macro | Role | -|-------|------| -| `#[capsec::context]` | **Generates** `Has

` impls on a context struct | -| `#[capsec::requires]` | **Validates** that a function's parameters satisfy the declared permissions | -| `#[capsec::deny]` | **Flags** (via audit tool) functions that should have zero I/O | - -The context macro generates the impls. The requires macro validates they exist. Without the requires upgrade, there's a gap between declaration and enforcement that the context pattern makes wider. - ---- - -## Decisions (Resolved) - -1. **Default `send` or `local`:** Default to `Cap

` (`!Send`). Matches capsec's explicit opt-in philosophy. The async world is the majority case for web services but the minority case for CLI tools, data pipelines, and the kind of code most likely to adopt capsec first. Use `#[capsec::context(send)]` for async. Document the async pattern prominently. - -2. **`Has

` for `SendCap

`:** Yes. Add `impl Has

for SendCap

`. Without it, the send context macro generates `.as_cap()` calls inside `cap_ref()` — unnecessary indirection. Direct `Has

` on `SendCap

` is cleaner. - -3. **Field visibility:** Private fields + generated accessor methods. Public fields would let users extract a `Cap

` and pass it around independently of the context, which isn't wrong but defeats the "single context reference" ergonomic goal. Users who need a raw cap can call `ctx.cap_ref()` via the `Has

` impl. - -4. **Context inheritance / composition:** Not in v1. Flat structs with permission fields only. Composition is a "nice problem to have" after people are actually using the context pattern. Can be added later without breaking changes. - -5. **Macro name:** `#[capsec::context]`. It's what the pattern is called in the DI literature. `cap_bundle` sounds like a marketing term. `grants` is ambiguous with `CapRoot::grant()`.