From df2dc1d975bbf865cc0957d41b57f8067d96d602 Mon Sep 17 00:00:00 2001 From: Matej Gomboc Date: Thu, 30 Jul 2026 21:04:43 +0200 Subject: [PATCH] feat(capability): value types for the capability table (RFC-0003, part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accepted capability design turns into code, first increment. A new no_std workspace crate, setonix-capability, holds the pure logic of the capability spine so it can be host-unit-tested — a bare-metal target has no test harness, and this is the most security-critical logic in the kernel, so it lives where it can be exercised exhaustively away from the hardware. This part is the value types the flat table is built from, each carrying a compiler-checked invariant rather than a rule a reviewer must remember: - Rights: a closed bitmask (DUPLICATE, TRANSFER, READ, WRITE, REVOKE) whose sole attenuation operation, diminish, is subset-only. O-2 (non-widenability) is a property of the type — there is no constructor from arbitrary bits and no operation that adds a right. An exhaustive test checks over all 32 combinations that a successful diminish returns a subset of its source. - Generation: a 64-bit counter, fail-closed on exhaustion (next returns None rather than wrapping to a value a stale handle could match). This is the mechanism behind O-1's reuse case and O-3's destruction case, and the fail-closed width is the RFC-0003 amendment made concrete. - Handle: the userspace-facing name — a table index plus the generation its slot held when minted. Carries no authority by itself. - CapabilityError: a fail-closed error for every table operation. Per the RFC-0003 prior-art amendment, the crate doc states plainly that the compile-time guarantees cover the kernel's internal handling; the userspace-observable cross-process transfer is a runtime table operation the generation scheme secures — the borrow checker cannot span protection domains, so the crate does not pretend it does. Authorship is AI-first under amended §5.3; this is pure architecture- independent logic with no unsafe and no assembly, so it is ordinary review, not the boot/asm sparring-partner regime. The crate is standalone for now — the kernel wires it in once there are kernel objects for capabilities to reference. Verified locally: fmt, spelling, pins, markdown clean; clippy clean under -D warnings; 12 host tests pass; builds for both Tier-1 targets (proving it stays no_std). CI gains a capability host-test group and a per-target build. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci_main.yml | 13 ++ .github/workflows/ci_pr.yml | 13 ++ CHANGELOG.md | 19 +++ CLAUDE.md | 7 ++ Cargo.lock | 4 + Cargo.toml | 2 +- capability/Cargo.toml | 21 ++++ capability/src/error.rs | 28 +++++ capability/src/generation.rs | 80 +++++++++++++ capability/src/handle.rs | 69 +++++++++++ capability/src/lib.rs | 41 +++++++ capability/src/rights.rs | 219 ++++++++++++++++++++++++++++++++++ 12 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 capability/Cargo.toml create mode 100644 capability/src/error.rs create mode 100644 capability/src/generation.rs create mode 100644 capability/src/handle.rs create mode 100644 capability/src/lib.rs create mode 100644 capability/src/rights.rs diff --git a/.github/workflows/ci_main.yml b/.github/workflows/ci_main.yml index 3b65129..1f3f70c 100644 --- a/.github/workflows/ci_main.yml +++ b/.github/workflows/ci_main.yml @@ -79,6 +79,11 @@ jobs: cargo clippy --package setonix-kernel --all-features --target "$target" -- -D warnings cargo build --verbose --package setonix-kernel --target "$target" cargo build --release --verbose --package setonix-kernel --target "$target" + # The capability crate is architecture-independent no_std + # logic; building it for both Tier-1 targets proves it + # stays no_std-clean. + cargo clippy --package setonix-capability --target "$target" -- -D warnings + cargo build --package setonix-capability --target "$target" echo "::endgroup::" done @@ -87,6 +92,14 @@ jobs: cargo test --verbose --package xtask echo "::endgroup::" + # Host-testable kernel logic. The capability table's pure logic + # lives in its own crate precisely so it can run a test harness + # (a bare-metal target cannot). + echo "::group::capability (host)" + cargo clippy --package setonix-capability --all-targets --all-features -- -D warnings + cargo test --verbose --package setonix-capability + echo "::endgroup::" + boot: name: Boot (QEMU aarch64 virt) needs: checks diff --git a/.github/workflows/ci_pr.yml b/.github/workflows/ci_pr.yml index e1e2313..b3cf340 100644 --- a/.github/workflows/ci_pr.yml +++ b/.github/workflows/ci_pr.yml @@ -81,6 +81,11 @@ jobs: cargo clippy --package setonix-kernel --all-features --target "$target" -- -D warnings cargo build --verbose --package setonix-kernel --target "$target" cargo build --release --verbose --package setonix-kernel --target "$target" + # The capability crate is architecture-independent no_std + # logic; building it for both Tier-1 targets proves it + # stays no_std-clean. + cargo clippy --package setonix-capability --target "$target" -- -D warnings + cargo build --package setonix-capability --target "$target" echo "::endgroup::" done @@ -89,6 +94,14 @@ jobs: cargo test --verbose --package xtask echo "::endgroup::" + # Host-testable kernel logic. The capability table's pure logic + # lives in its own crate precisely so it can run a test harness + # (a bare-metal target cannot). + echo "::group::capability (host)" + cargo clippy --package setonix-capability --all-targets --all-features -- -D warnings + cargo test --verbose --package setonix-capability + echo "::endgroup::" + boot: name: Boot (QEMU aarch64 virt) needs: checks diff --git a/CHANGELOG.md b/CHANGELOG.md index a505a38..6908782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ Release codenames follow the six Noongar seasons — Birak, Bunuru, Djeran, Maku ### Added +- **The capability table begins — RFC-0003 turns into code, first increment.** A new `no_std` + workspace crate, `setonix-capability`, holding the pure, architecture-independent logic of the + capability spine so it can be **host-unit-tested** — a bare-metal target has no test harness, so the + security-critical logic lives where it can be exercised exhaustively. This increment is the value + types the table is built from, each with a compiler-checked invariant: + - `Rights` — a closed bitmask (`DUPLICATE`, `TRANSFER`, `READ`, `WRITE`, `REVOKE`) whose only + attenuation operation, `diminish`, is subset-only. Non-widenability (O-2) is a property of the + type: there is no constructor from arbitrary bits and no operation that adds a right. A test + checks *exhaustively* over all 32 rights combinations that a successful `diminish` is always a + subset of its source. + - `Generation` — a 64-bit counter, **fail-closed on exhaustion** (`next` returns `None` rather than + wrapping to a value a stale handle could match), the defence behind O-1's reuse case and O-3's + destruction case. + - `Handle` — the userspace-facing name: a table index plus the generation its slot held when + minted; carries no authority by itself. + - `CapabilityError` — a fail-closed error for every table operation. + Twelve host tests, clippy-clean under `-D warnings`, and built for both Tier-1 targets to prove it + stays `no_std`. CI gains a capability host-test group and per-target build. The owned no-`Clone` + `Capability` value and the flat table follow in the next increment (§5.3: small, reviewable steps). - **Exception vectors, and a reporter that says what went wrong in one line** — the instrument the soft-float bug had to be diagnosed without. `boot.s` installs a 2 KiB-aligned sixteen-entry vector table into `VBAR_EL1` before the first Rust instruction runs, so even the earliest fault is reported diff --git a/CLAUDE.md b/CLAUDE.md index 8cd830e..7e3c6e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,9 +38,16 @@ kernel/ the kernel crate (no_std, no_main) src/main.rs architecture-independent entry; contains no unsafe, ever src/arch/ the HAL boundary — the only tree that knows the architecture link/ per-architecture linker scripts +capability/ the capability table (RFC-0003): pure no_std logic, host-tested xtask/ build, run and boot-test automation (host binary, no deps) ``` +Architecture-independent kernel logic that benefits from host unit tests lives in +its own `no_std` workspace crate (like `capability/`), because the kernel crate +cannot run a test harness on a bare-metal target. Such a crate is `#![no_std]` in +the kernel build and `std` under `cargo test`; it builds for both Tier-1 targets +in CI to prove it stays `no_std`. + ## Architectures Tier 1, both first-class from day one: `aarch64-unknown-none-softfloat` and diff --git a/Cargo.lock b/Cargo.lock index c7b170b..007f410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,10 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "setonix-capability" +version = "0.0.0" + [[package]] name = "setonix-kernel" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index c94b224..5053f40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ [workspace] resolver = "3" -members = ["kernel", "xtask"] +members = ["kernel", "xtask", "capability"] [workspace.package] edition = "2024" diff --git a/capability/Cargo.toml b/capability/Cargo.toml new file mode 100644 index 0000000..8c8bbec --- /dev/null +++ b/capability/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "setonix-capability" +version = "0.0.0" +description = "The Setonix capability table — unforgeable, rights-attenuating object references (RFC-0003)" +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +publish = false + +# No dependencies, deliberately. A security-critical primitive is read far more +# often than it is written, and every line of it must be explainable (§5.2). +# `#![no_std]` in the kernel build, `std` under `cargo test` so the pure logic +# is host-unit-tested — the kernel crate cannot run a test harness on bare metal. + +[lib] +name = "setonix_capability" +path = "src/lib.rs" + +[lints] +workspace = true diff --git a/capability/src/error.rs b/capability/src/error.rs new file mode 100644 index 0000000..5433417 --- /dev/null +++ b/capability/src/error.rs @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Errors from capability-table operations. + +/// Why a capability-table operation failed. +/// +/// Every variant fails closed: the operation grants no authority and changes no +/// state a caller could mistake for success. There is no "partial" outcome. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum CapabilityError { + /// The handle's index lies outside the table. + OutOfBounds, + /// The slot the handle names is empty. + Empty, + /// The slot's generation no longer matches the handle's: the capability it + /// named has been closed, or the slot reused — a stale handle (RFC-0003 O-1). + StaleGeneration, + /// The requested rights are not a subset of the source capability's: a + /// widening was attempted, which the type refuses (RFC-0003 O-2). + RightsNotSubset, + /// The source capability lacks `DUPLICATE`, so nothing may be derived from it. + NotDuplicable, + /// The table has no free slot to mint into. + TableFull, + /// A slot's generation counter is exhausted; the slot is retired rather than + /// wrapped (the fail-closed boundary — see [`Generation`](crate::Generation)). + GenerationExhausted, +} diff --git a/capability/src/generation.rs b/capability/src/generation.rs new file mode 100644 index 0000000..735472f --- /dev/null +++ b/capability/src/generation.rs @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Generation counters — the defence against handle reuse (the ABA problem). + +/// A counter that distinguishes successive occupants of one table slot, and +/// successive incarnations of one object. +/// +/// A [`Handle`](crate::Handle) records the generation its slot held when the +/// handle was minted; resolving the handle checks that generation still matches. +/// Closing a capability and reusing its slot, or destroying an object, bumps the +/// generation — so a stale handle fails closed rather than resolving to whatever +/// now occupies the slot. This is the mechanism behind RFC-0003 O-1 +/// (unforgeability against reuse) and the destruction half of O-3 (revocation by +/// making outstanding capabilities inert). +/// +/// 64 bits wide and **fail-closed on exhaustion** (RFC-0003 amendment): a slot +/// that somehow exhausts its generations is retired, never wrapped back to a +/// value a live handle might match. At 2^64 reuses of a single slot, exhaustion +/// is unreachable in practice; the type refuses to wrap regardless, so the +/// guarantee does not rest on that improbability. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)] +pub struct Generation(u64); + +impl Generation { + /// The generation of a freshly created slot or object. Starts at 1, leaving + /// 0 available as a "never allocated" sentinel for later use. + pub const FIRST: Self = Self(1); + + /// The next generation, or [`None`] if this one is the last representable — + /// the fail-closed boundary. A caller that receives [`None`] must retire the + /// slot, never reuse it. + #[must_use] + pub const fn next(self) -> Option { + match self.0.checked_add(1) { + Some(n) => Some(Self(n)), + None => None, + } + } + + /// The raw value, for packing into a handle or comparing on resolve. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +#[cfg(test)] +#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::Generation; + + #[test] + fn first_is_one_and_advances() { + assert_eq!(Generation::FIRST.get(), 1); + assert_eq!(Generation::FIRST.next(), Some(Generation(2))); + assert_eq!(Generation(2).next(), Some(Generation(3))); + } + + #[test] + fn generations_are_strictly_increasing_and_distinct() { + let mut g = Generation::FIRST; + let mut previous = None; + for _ in 0..1000 { + if let Some(p) = previous { + assert!(g > p); + } + previous = Some(g); + g = g.next().expect("u64 does not exhaust in 1000 steps"); + } + } + + #[test] + fn exhaustion_fails_closed_rather_than_wrapping() { + // The whole point: at the boundary, `next` returns None instead of + // wrapping to a value a stale handle could match. + assert_eq!(Generation(u64::MAX).next(), None); + // And one before the boundary still advances. + assert_eq!(Generation(u64::MAX - 1).next(), Some(Generation(u64::MAX))); + } +} diff --git a/capability/src/handle.rs b/capability/src/handle.rs new file mode 100644 index 0000000..7c4bec2 --- /dev/null +++ b/capability/src/handle.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Handles — the userspace-facing name for a capability. + +use crate::Generation; + +/// The value a process holds to name one of its own capabilities: an index into +/// its capability table, plus the [`Generation`] the slot held when the handle +/// was minted. +/// +/// A handle carries no authority by itself and is meaningless outside the +/// process that holds it — it is a lookup key the kernel resolves against a table +/// only the kernel can write (RFC-0003 §3). Two things make it safe to hand to +/// userspace: the capability it names lives in kernel memory, so there is +/// nothing to forge; and the generation makes a stale handle fail closed instead +/// of aliasing a reused slot. Handle `7` in one process and handle `7` in another +/// name unrelated capabilities, or none. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub struct Handle { + index: u32, + generation: Generation, +} + +impl Handle { + /// Constructs a handle naming slot `index` at `generation`. Minted by the + /// table when it stores a capability; a value userspace holds but cannot + /// turn into authority it was not granted, because every resolve re-checks + /// the generation against the live slot. + #[must_use] + pub const fn new(index: u32, generation: Generation) -> Self { + Self { index, generation } + } + + /// The table slot this handle names. + #[must_use] + pub const fn index(self) -> u32 { + self.index + } + + /// The generation this handle was minted at. + #[must_use] + pub const fn generation(self) -> Generation { + self.generation + } +} + +#[cfg(test)] +#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::Handle; + use crate::Generation; + + #[test] + fn round_trips_index_and_generation() { + let h = Handle::new(7, Generation::FIRST); + assert_eq!(h.index(), 7); + assert_eq!(h.generation(), Generation::FIRST); + } + + #[test] + fn handles_differing_in_generation_are_distinct() { + let g2 = Generation::FIRST.next().expect("second generation exists"); + assert_ne!(Handle::new(7, Generation::FIRST), Handle::new(7, g2)); + // Same slot, same generation: equal. + assert_eq!(Handle::new(7, g2), Handle::new(7, g2)); + // Same generation, different slot: distinct. + assert_ne!(Handle::new(7, g2), Handle::new(8, g2)); + } +} diff --git a/capability/src/lib.rs b/capability/src/lib.rs new file mode 100644 index 0000000..83024b6 --- /dev/null +++ b/capability/src/lib.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The Setonix capability table — RFC-0003. +//! +//! Unforgeable, rights-attenuating references to kernel objects. This crate +//! holds the *mechanism*: architecture-independent, `no_std`, allocation-free +//! logic, unit-tested on the host. It lives outside the kernel crate precisely +//! so it *can* be host-tested — a bare-metal target has no test harness — and +//! so the security spine can be exercised exhaustively away from the hardware. +//! +//! This first increment is the value types the table is built from. The owned, +//! no-`Clone` capability and the flat per-process table follow, one reviewable +//! step at a time (§5.3). +//! +//! What the design guarantees, and where it lives: +//! +//! - **Unforgeability (O-1).** Userspace holds a [`Handle`] — a table index plus +//! a [`Generation`] — never the capability itself, which is kernel memory. A +//! stale handle fails closed against the generation rather than aliasing a +//! reused slot. +//! - **Non-widenability (O-2).** [`Rights`] only ever *diminish*: no operation +//! anywhere adds a right, and [`Rights::diminish`] is the sole way to change a +//! held capability's rights. +//! +//! Per the RFC-0003 prior-art amendment, the compile-time guarantees here cover +//! the kernel's *internal* handling; the userspace-observable cross-process +//! transfer is a runtime table operation the generation scheme secures — the +//! borrow checker cannot span protection domains, so this crate does not pretend +//! it does. + +#![cfg_attr(not(test), no_std)] + +mod error; +mod generation; +mod handle; +mod rights; + +pub use error::CapabilityError; +pub use generation::Generation; +pub use handle::Handle; +pub use rights::Rights; diff --git a/capability/src/rights.rs b/capability/src/rights.rs new file mode 100644 index 0000000..4963a0d --- /dev/null +++ b/capability/src/rights.rs @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Capability rights — the attenuable authority a capability carries. + +use core::fmt; + +/// The set of operations a capability permits on its object. +/// +/// A small, closed bitmask. Rights only ever *diminish* along a derivation +/// chain: [`Rights::diminish`] can drop bits, and no operation anywhere adds +/// one. That is RFC-0003 O-2 (non-widenability) made a property of the type +/// rather than a rule a reviewer must remember. +/// +/// There is deliberately no constructor from arbitrary bits. A `Rights` is only +/// ever assembled from the named constants (with [`Rights::union`] at mint time) +/// and narrowed with [`Rights::diminish`] — never conjured from an integer a +/// caller supplies, which is what keeps an undefined bit from ever meaning +/// authority. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Rights(u32); + +impl Rights { + /// No rights: a capability that permits nothing, though it still names its + /// object. + pub const NONE: Self = Self(0); + /// May derive further capabilities from this one (see [`diminish`](Self::diminish)). + pub const DUPLICATE: Self = Self(1 << 0); + /// May transfer this capability to another process (in an IPC message). + pub const TRANSFER: Self = Self(1 << 1); + /// May read the object's state, or receive from it. + pub const READ: Self = Self(1 << 2); + /// May modify the object's state, or send to it. + pub const WRITE: Self = Self(1 << 3); + /// May revoke capabilities derived from this one. + pub const REVOKE: Self = Self(1 << 4); + + /// Every defined right — the union of all named bits. Computed from the + /// constants rather than written as a literal, so adding a right above + /// extends this automatically and no undefined bit is ever included. + pub const ALL: Self = + Self(Self::DUPLICATE.0 | Self::TRANSFER.0 | Self::READ.0 | Self::WRITE.0 | Self::REVOKE.0); + + /// Whether `self` includes every right in `other`. + #[must_use] + pub const fn contains(self, other: Self) -> bool { + (self.0 & other.0) == other.0 + } + + /// Whether every right in `self` is also in `other`. + #[must_use] + pub const fn is_subset_of(self, other: Self) -> bool { + other.contains(self) + } + + /// The rights present in both `self` and `other`. + #[must_use] + pub const fn intersection(self, other: Self) -> Self { + Self(self.0 & other.0) + } + + /// Combine two rights sets — for *assembling* an initial set at mint time, + /// e.g. `Rights::READ.union(Rights::WRITE)`. + /// + /// This is a constructor convenience, not an operation on a held capability: + /// the only way a *held* capability's rights change is [`diminish`](Self::diminish), + /// which cannot widen. Union is never applied to narrow an existing + /// capability, so O-2 is not at risk from its existence. + #[must_use] + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + /// Attenuate to `requested` — the O-2 operation. Succeeds only if `requested` + /// is a subset of `self`, so a derivation can never gain a right its parent + /// lacked. Returns [`None`] if `requested` asks for a right `self` does not + /// hold. + #[must_use] + pub const fn diminish(self, requested: Self) -> Option { + if self.contains(requested) { + Some(requested) + } else { + None + } + } + + /// The raw bits, for packing into a stored capability or an IPC message. + #[must_use] + pub const fn bits(self) -> u32 { + self.0 + } +} + +impl fmt::Debug for Rights { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Rights(")?; + let mut first = true; + for (name, bit) in [ + ("DUPLICATE", Self::DUPLICATE), + ("TRANSFER", Self::TRANSFER), + ("READ", Self::READ), + ("WRITE", Self::WRITE), + ("REVOKE", Self::REVOKE), + ] { + if self.contains(bit) { + if !first { + write!(f, "|")?; + } + write!(f, "{name}")?; + first = false; + } + } + if first { + write!(f, "NONE")?; + } + write!(f, ")") + } +} + +#[cfg(test)] +#[allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::Rights; + + #[test] + fn all_contains_every_named_right() { + for bit in [ + Rights::DUPLICATE, + Rights::TRANSFER, + Rights::READ, + Rights::WRITE, + Rights::REVOKE, + ] { + assert!(Rights::ALL.contains(bit)); + } + } + + #[test] + fn none_contains_only_none() { + assert!(Rights::ALL.contains(Rights::NONE)); + assert!(Rights::NONE.contains(Rights::NONE)); + assert!(!Rights::NONE.contains(Rights::READ)); + } + + #[test] + fn subset_is_the_inverse_of_contains() { + let rw = Rights::READ.union(Rights::WRITE); + assert!(rw.is_subset_of(Rights::ALL)); + assert!(Rights::READ.is_subset_of(rw)); + assert!(!rw.is_subset_of(Rights::READ)); + } + + #[test] + fn diminish_narrows_but_never_widens() { + let rw = Rights::READ.union(Rights::WRITE); + // Narrowing to a held subset succeeds and yields exactly that subset. + assert_eq!(rw.diminish(Rights::READ), Some(Rights::READ)); + assert_eq!(rw.diminish(rw), Some(rw)); + assert_eq!(rw.diminish(Rights::NONE), Some(Rights::NONE)); + // Asking for a right not held fails closed — the O-2 guarantee. + assert_eq!(rw.diminish(Rights::TRANSFER), None); + assert_eq!(Rights::READ.diminish(Rights::WRITE), None); + assert_eq!(Rights::NONE.diminish(Rights::READ), None); + } + + #[test] + fn diminish_result_is_always_a_subset_of_the_source() { + // Exhaustively, for every source/request pair over the 5 defined bits, + // a successful diminish returns a subset of the source. This is the + // property O-2 rests on, checked rather than asserted. + for s in 0u32..32 { + for r in 0u32..32 { + let source = rights_from_low_bits(s); + let requested = rights_from_low_bits(r); + if let Some(result) = source.diminish(requested) { + assert!(result.is_subset_of(source)); + } + } + } + } + + #[test] + fn intersection_and_union_are_dual() { + let rw = Rights::READ.union(Rights::WRITE); + let wt = Rights::WRITE.union(Rights::TRANSFER); + assert_eq!(rw.intersection(wt), Rights::WRITE); + assert!(rw.union(wt).contains(Rights::READ)); + assert!(rw.union(wt).contains(Rights::TRANSFER)); + } + + #[test] + fn debug_lists_set_bits() { + assert_eq!(format!("{:?}", Rights::NONE), "Rights(NONE)"); + assert_eq!(format!("{:?}", Rights::READ), "Rights(READ)"); + assert_eq!( + format!("{:?}", Rights::READ.union(Rights::WRITE)), + "Rights(READ|WRITE)" + ); + } + + /// Build a `Rights` from the low 5 bits of `n`, for exhaustive testing only. + fn rights_from_low_bits(n: u32) -> Rights { + let mut r = Rights::NONE; + for (i, bit) in [ + Rights::DUPLICATE, + Rights::TRANSFER, + Rights::READ, + Rights::WRITE, + Rights::REVOKE, + ] + .into_iter() + .enumerate() + { + if n & (1 << i) != 0 { + r = r.union(bit); + } + } + r + } +}