diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 16158bbab..4a3756cb3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -151,9 +151,9 @@ Owns the narrow WebDriver BiDi adapter contract that is expressible by one expli ### `originweave-browser-session` (active PR) -Owns the Browser Session aggregate boundary for disposable context lifecycle and presentation-mutation authority. Raw `BrowserSessionId` and `BrowsingContextId` values are transport addressability only. A context enters the owned set only after the narrow `DisposableContextPort` reports a fresh disposable isolation boundary together with its browsing-context address. The aggregate stores that exact handle and issues a non-caller-constructible `PresentationMutationAuthority` bound to browser-session identity, disposable-isolation identity, browsing context, and monotonic context epoch. +Owns the Browser Session aggregate boundary for disposable context lifecycle and presentation-mutation authority. Raw `BrowserSessionId` and `BrowsingContextId` values are transport addressability only. A context enters the owned set only after the narrow `DisposableContextPort` reports a fresh disposable isolation boundary together with its browsing-context address. The aggregate stores that exact handle and issues a non-caller-constructible `PresentationMutationAuthority` bound to browser-session identity, `BrowserSessionIncarnation`, disposable-isolation identity, browsing context, and monotonic context epoch. `BrowserSessionIncarnation` participates in authorization validation and prevents sequential ABA when external session/context identifiers and local epoch values are reused. -The isolation identity prevents distinct aggregate incarnations from aliasing authority when external session/context identifiers and local epoch values are reused. Destruction validates the full authority before adapter I/O and passes the stored isolation handle back to the port; cleanup authority is never reconstructed from `(BrowserSessionId, BrowsingContextId)`. For a WebDriver BiDi adapter, the port contract requires a one-to-one mapping from the domain's `DisposableIsolationId` to the specification-defined unique user-context id created for that live boundary. The protocol identifier is lifecycle addressability, not OriginWeave policy authority. Stale, foreign-session, foreign-isolation, unknown, destroyed, or uncertain authority fails closed; failed destruction makes the context uncertain; browser transport loss invalidates active authority; and normal session end is rejected until every owned boundary has proven destruction. +The disposable-isolation identity binds lifecycle ownership to the exact browser isolation boundary; it does not substitute for `BrowserSessionIncarnation`. Destruction validates the full authority before adapter I/O and passes the stored isolation handle back to the port; cleanup authority is never reconstructed from `(BrowserSessionId, BrowsingContextId)`. For a WebDriver BiDi adapter, the port contract requires a one-to-one mapping from the domain's `DisposableIsolationId` to the specification-defined unique user-context id created for that live boundary. The protocol identifier is lifecycle addressability, not OriginWeave policy authority. Stale, foreign-session, foreign-isolation, unknown, destroyed, or uncertain authority fails closed; failed destruction makes the context uncertain; browser transport loss invalidates active authority; and normal session end is rejected until every owned boundary has proven destruction. This active slice deliberately stops before browser transport. WebDriver BiDi/CDP remain adapters and do not mint policy authority. The current proposal does not yet bridge domain authority into `originweave-bidi`'s private presentation/screen-area witnesses, implement the real `browser.createUserContext`/`browsingContext.create`/`browser.removeUserContext` adapter, prove exact-boundary cleanup post-conditions in Chromium, or establish protected-main behavior. ADR 0114, the Browser Session traceability dossier, and the lifecycle UML record those remaining boundaries. diff --git a/CHANGELOG.md b/CHANGELOG.md index a317fc24e..d1b15dc9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,62 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Fixed +- Failed closed when Browser Session production Rust source selects native libraries through direct `#[link(...)]` or `cfg_attr(..., link(...))` attributes, while the shared Rust attribute lexer treats comment and string/character/raw-string text as lexical data rather than authority. +- Failed closed when Browser Session production Rust source embeds compile-time files through direct or namespaced `include_bytes!` / `include_str!` and callable `use ... as ...` aliases, reusing the canonical production-source closure and shared Rust source-indirection lexer instead of rediscovering Cargo topology. +- Hardened the shared Rust `use` / `as` keyword boundary to follow Unicode XID continuation semantics instead of Python word-character heuristics, so combining-mark continuations such as U+0301 cannot be misread as a keyword or discard-alias boundary; the compile-time-environment supplement consumes that shared lexer owner rather than retaining duplicate `use` / `as` token authority. +- Failed closed when Browser Session production Rust source injects executable source through lexical `include!` invocations or callable aliases, while comment, ordinary/raw string, character-literal, and commented grouped-use text remain lexical data rather than source authority. +- Failed closed when custom Cargo target roots use lexical Rust `mod` tokens that can resolve additional module source outside Cargo's default `src/**/*.rs` sibling closure; comment/string/character/raw-string text and identifier-adjacent lookalikes no longer create false authority. +- Failed closed when Browser Session production Rust attributes select module source through lexical `path = ...`, including raw-identifier `r#path`, while comment and ordinary/raw string/character-literal text inside attributes remains data rather than source authority. +- Failed closed when Browser Session production Rust source binds artifact content to ambient build values through direct or namespaced `env!` / `option_env!`; raw-string, character-literal resumption, and namespaced `option_env!` regression coverage preserve lexical correctness, while runtime `std::env` access remains a separate runtime authority. +- Failed closed when repository-owned Cargo `rustdocflags` select an external scrape-examples calls file through unstable rustdoc `--with-examples`, covering build split-form, target equals-form, and nightly host configuration while keeping output-only `--scrape-examples-output-path` outside input-authority classification. +- Failed closed when repository-owned Cargo nightly `[host]` / `[host.]` configuration selects host linker/runner execution, authority-extending rustc/rustdoc flags, or host-tuple `links` build-script overrides, while unrelated optimization/documentation flags remain permitted. +- Recorded empty nested host `links` override tables as build-script authority too: Cargo can materialize `BuildOutput::default()` for an empty override and thereby suppress the matching build script, so an empty table is not equivalent to absence of override authority. +- Failed closed when a repository-owned generic nested `[host.]` map can occupy Cargo `TargetConfig.links_overrides` authority: unknown nested host maps are treated as potential build-script-output overrides instead of being guessed harmless from tuple spelling, while typed direct host settings retain their existing classifiers. +- Corrected Cargo target classification so unknown nested tables under `[target.'cfg(...)']` are not misclassified as concrete target-tuple `links` build-script overrides; typed cfg-target linker/runner/rustflags/rustdocflags remain fail closed, while concrete target tuples retain nested `links` override enforcement. +- Failed closed when repository-owned Rust/rustdoc linker forwarding opens LLD's LLVMgold-compatible `plugin-opt=-...` opaque LLVM option-processing tunnel, including one-/two-dash spellings, so arbitrary LLVM options cannot bypass reviewed deterministic linker authority. +- Failed closed when repository-owned Rust/rustdoc linker forwarding loads LLD pass-plugin libraries through separated/joined `--load-pass-plugin`, keeping executable LTO pass code inside reviewed toolchain/plugin provenance. +- Failed closed when repository-owned Rust/rustdoc linker forwarding selects Distributed ThinLTO distributor/remote-compiler subprocess authority or forwards their argv through `--thinlto-distributor-arg`, `--thinlto-remote-compiler-prepend-arg`, and `--thinlto-remote-compiler-arg`, preventing repository-owned linker flags from opening unreviewed subprocess/toolchain authority. +- Failed closed when repository-owned Rust/rustdoc linker forwarding selects native libraries through GNU-compatible long-form `--library` / `--library=` aliases, so joined double-dash library selection cannot bypass the reviewed external-input authority boundary. +- Failed closed when repository-owned Rust/rustdoc linker forwarding selects LLD `--thinlto-cache-dir=`, preventing mutable cached native ThinLTO objects from becoming unreviewed link inputs outside the reviewed Cargo package/source closure. +- Failed closed when repository-owned Rust/rustdoc linker forwarding selects context-sensitive LTO PGO profiles through LLD `--lto-cs-profile-file=` or its one-/two-dash `plugin-opt=cs-profile-path=` aliases, keeping profile-guided code generation inside reviewed build-input provenance. +- Failed closed when repository-owned Rust/rustdoc linker forwarding selects LLD layout/profile files through `call-graph-ordering-file`, `irpgo-profile`, `symbol-ordering-file`, `lto-sample-profile`, or `plugin-opt=sample-profile=`, preventing unreviewed external layout/profile material from changing section placement or LTO decisions. +- Failed closed when repository-owned Rust/rustdoc linker forwarding selects an existing ARM CMSE secure-code import library through LLD `--in-implib=`, while leaving output-only `--out-implib=` outside input-authority classification. +- Failed closed when repository-owned Rust/rustdoc linker forwarding rewrites reviewed link inputs through GNU-compatible `--remap-inputs` / `--remap-inputs-file` spellings, including their single-dash aliases. +- Failed closed when repository-owned Rust/rustdoc linker forwarding imports external section-ordering policy through GNU-compatible `--section-ordering-file`, including the valid single-dash spelling. +- Failed closed when repository-owned Rust/rustdoc linker forwarding opens LLD's opaque LLVM option-processing tunnel through `--mllvm` / `-mllvm`, rather than treating unknown LLVM options as reviewed deterministic linker policy. +- Failed closed when repository-owned Rust/rustdoc linker forwarding changes the default library search path through GNU `-Y`, including compact `-Ypath` syntax. +- Failed closed when repository-owned Rust/rustdoc linker forwarding embeds ELF runtime filter/auxiliary resolution through GNU-compatible `-f` / `--auxiliary` and `-F` / `--filter`, preventing unreviewed runtime shared objects from becoming symbol-resolution authority. +- Failed closed when repository-owned Rust/rustdoc linker forwarding selects ELF rtld-audit libraries through `--audit`, `--depaudit`, or `-P`, preventing unreviewed runtime audit code from being recorded in the linked artifact. +- Failed closed when repository-owned Rust/rustdoc linker forwarding changes or removes the ELF runtime interpreter through `-I`, `--dynamic-linker`, or `--no-dynamic-linker`. +- Failed closed when repository-owned Rust/rustdoc linker forwarding selects GNU symbol-policy files through `--version-script`, `--dynamic-list`, `--retain-symbols-file`, or `--export-dynamic-symbol-list`, preventing unreviewed external symbol visibility/retention policy from changing the linked artifact outside the reviewed Cargo package/source closure. +- Failed closed when repository-owned Rust/rustdoc linker forwarding selects GNU-compatible `-R` / `--just-symbols`, preventing an unreviewed symbol-address file or ambiguous rpath operand from changing link behavior outside the reviewed Cargo package/source closure. +- Failed closed when repository-owned Rust/rustdoc linker forwarding selects GNU `-c` / `--mri-script`, preventing an unreviewed MRI command file from changing link behavior outside the reviewed Cargo package/source closure. +- Failed closed when repository-owned Rust/rustdoc linker forwarding selects GNU/LLD `-dT` / `--default-script`, preventing an unreviewed default linker script from changing link behavior while the reviewed Cargo package/source closure remains unchanged. +- Failed closed when repository-owned Rust/rustdoc linker forwarding selects GNU/LLD `--sysroot`, preventing an unreviewed linker search root from changing external system-library inputs while the reviewed Cargo package/source closure remains unchanged. +- Failed closed on Git-owned Cargo linker/driver execution extensions that re-select executables or subprocess authority through rustc/rustdoc linker flags (`linker=`, `-fuse-ld=`, driver `-B`, opaque `@file`, GCC `-specs=` / `-specs `, or GCC `-wrapper`) or dynamically load GNU linker plugin code through `-Wl,`, `--for-linker=`, and `-Xlinker`; unrelated linker forwarding remains permitted. +- Centralized Browser Session production-source provenance in the canonical trusted-adapter scanner so lifecycle-SPI, dependency, lifecycle-binding, and source-containment contracts consume one Cargo source closure; resolved source objects must remain inside the exact-head repository review root and dangling/default-source symlinks fail closed. +- Failed closed on production Cargo build surfaces (`build.rs`, enabled `[package] build`, top-level `[build-dependencies]`, and target-specific build dependencies) until generated-source provenance is explicitly modeled; `package.build = false` remains the explicit no-build-script form. +- Repaired Browser Session repository contracts so custom `[lib].path` / `[[bin]].path` fixtures reach the intended target-source assertion, recovery-custody accessor prohibitions inspect only `BoundBrowserSessionRecovery` inherent impls, and settlement ordering verifies delegated fact selection before proof I/O rather than duplicating selector internals. +- Extended the Browser Session trusted-adapter source review to Cargo production targets declared through custom `[lib].path` and `[[bin]].path`, so an already reviewed Browser Session-dependent crate cannot add lifecycle-SPI source outside `src/` without entering the exact source allowlist; declared production target paths outside the repository review root or naming missing files fail closed. +- Derived Browser Session trusted-adapter source and manifest review coverage from explicit Cargo `[workspace].members` instead of the `crates/*` directory convention, so production workspace members at other paths cannot evade lifecycle-SPI/dependency/binding review; workspace-member globs now fail closed until the security contract is explicitly extended. +- Hardened Browser Session trusted-adapter dependency discovery so Cargo workspace-inherited aliases and target-specific production dependencies resolve to the canonical `originweave-browser-session` package before allowlist comparison; dev-only dependencies do not widen the shipped adapter-composition surface. +- Removed latent Browser Session adapter pre-authorization from the trusted-adapter allowlists. Approved source and dependency entries must now correspond to production surfaces present on the same exact tree, so the future BiDi lifecycle adapter must widen the allowlists in the same reviewed change that introduces its implementation and crate dependency. +- Hardened the Browser Session trusted-adapter repository contract so fully qualified or aliased `DisposableContextPort` references and UFCS/whitespace `bind_lifecycle_port` spellings cannot evade review-surface detection; this changes no Rust production behavior or trust classification. +- Corrected the root Browser Session architecture contract so `PresentationMutationAuthority` is explicitly bound to `BrowserSessionIncarnation`; the incarnation participates in authorization validation and provides sequential-ABA separation when external session/context identifiers and local epochs are reused, while the disposable-isolation identity remains the exact remote lifecycle boundary rather than a substitute for aggregate incarnation. +- Prevented ownership-clean `TransportLost` sessions from entering Browser Session recovery custody. Recovery handoff now requires exact unresolved recovery/create-attempt evidence for `TransportLost`, while `RecoveryRequired` remains recovery-eligible; transport loss before remote ownership or after proven destruction therefore cannot mint a purpose-bounded adapter-operation capability. +- Preserved Browser Session create-attempt provenance through uncertain creation, duplicate-candidate rejection, and accepted/rejected completion-settlement failure. Recovery now keeps aggregate-issued attempt epoch, disposition, and complete candidate identity without collapsing a previously accepted same-valued owner into later candidate evidence; the abandonment/incarnation atomic updates use `AtomicU64::try_update` without changing their memory ordering or overflow behavior. +- Added one-way same-adapter Browser Session recovery custody for `RecoveryRequired` and for `TransportLost` with retained unresolved ownership evidence through `BoundBrowserSessionRecovery

`, preventing recovery evidence from regaining raw adapter or ordinary command authority. Proven destruction now retires only the exact live hot ownership record after adapter-proven success; failed destruction keeps `Uncertain` ownership with exact `UnprovenDestruction { context, context_epoch }` evidence. - Prevented the reusable profile-derived WebDriver BiDi planner from scheduling `setScreenSettingsOverride` from `ScreenMetrics` alone, because the standard operation also changes the page-observable available screen rectangle that the current presentation identity neither selects nor digest-binds. - Restored canonical Rust formatting for the WebDriver BiDi presentation cleanup assertion so exact-head contracts can execute the test, Clippy, and rustdoc gates. ### Added -- Added a version-pinned `originweave-bidi` presentation-capability boundary for the W3C WebDriver BiDi Working Draft published on 3 September 2026 (`https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/`). It depends inward on `originweave-fingerprint`, keeps the reusable plan limited to symmetrically restorable and explicitly modelled viewport/DPR and timezone commands, and exposes screen settings as a separate typed partial intent whose one rectangle controls both total and available screen area. Complete `PresentationSurface::Screen` admission still fails closed because available-screen geometry is unmodelled and color depth remains uncontrolled. Reduced motion remains an expressible protocol capability but is not installed by the reusable plan because standard cleanup cannot selectively restore prior media state. No caller-mintable exclusive-reset type is exposed; a Browser Session owner must prove a disposable lifecycle or complete prior-state restoration. Planning performs no transport I/O or acknowledgement, cleanup, ownership, or page-observed evidence. Hardware concurrency and the complete Chromium platform/User-Agent Client Hints surface still require a separate versioned Chromium adapter. +- Added exact Browser Session create-recovery transaction evidence for `CreateFailedUncertain(Some/None)`, duplicate candidates, and unsettled `Accepted|Rejected` completions, plus a hostile same-valued-handle fixture proving candidate facts and prior ownership facts remain distinct. +- Added a 258-generation same-raw-context hostile acceptance proving proven-destroy hot-state retirement, monotonic context epochs, and predecessor-authority rejection before lifecycle I/O. +- Added an `originweave-bidi` presentation-capability boundary referenced against the W3C WebDriver BiDi Working Draft published on 9 September 2026 (`https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/`). That dated document is publication/reference evidence; the separately runtime-qualified compatibility pin remains the 3 September 2026 Working Draft until independent schema/semantics/conformance and pinned-Chromium evidence admit another revision. The boundary depends inward on `originweave-fingerprint`, keeps the reusable plan limited to symmetrically restorable and explicitly modelled viewport/DPR plus timezone commands, and exposes screen settings as a separate typed partial intent whose one rectangle controls both total and available screen area. Complete `PresentationSurface::Screen` admission still fails closed because available-screen geometry is unmodelled and color depth remains uncontrolled. Reduced motion remains an expressible protocol capability but is not installed by the reusable plan because standard cleanup cannot selectively restore prior media state. No caller-mintable exclusive-reset type is exposed; a Browser Session owner must prove a disposable lifecycle or complete prior-state restoration. Planning performs no transport I/O or acknowledgement, cleanup, ownership, or page-observed evidence. Hardware concurrency and the complete Chromium platform/User-Agent Client Hints surface still require a separate versioned Chromium adapter. - Added a bounded Rust presentation-identity kernel for explicit browser-visible profiles and credential-free replay digests, including control-safe mobile UA-CH model values; applying those profiles to Chromium and proving page-observed effects remain separate adapter and browser-E2E work. - Corrected the 2026-08-26 product-gap snapshot with current #229 presentation-identity evidence, stacked-only #205 integration evidence, current base/head pairs, the 126-PR queue count, explicit root-versus-child merge ordering, and the active GitHub counted-approval gate. -- Refreshed the product and technical gap baseline onto the 2026-08-26 live inventory: 126 open pull requests (54 ready, 72 draft), protected-main promotion of #168/#194/#196/#216/#151, a verified maintenance-loop record (supersession closure of #153, conflict reconciliations on #37/#149/#152/#173/#175, issue #212 option-(b) authorization on #43, Strix vuln-0001 homoglyph remediation on #124), provider-rerun outcome evidence, an organization review-pipeline congestion record, and refreshed merge-order queue guidance. Documentation evidence contracts were aligned to the same snapshot so the baseline, its dated markers, and the pinned exact-head rows cannot silently diverge. +- Refreshed the product and technical gap baseline onto the 2026-08-26 live inventory: 126 open pull requests (54 ready, 72 draft), protected-main promotion of #168/#194/#196/#216/#151, a verified maintenance-loop record (supersession closure of #153, conflict reconciliations on #37/#149/#152/#173/#175, issue #212 option-(b) authorization on #43, Strix vuln-0001 homoglyph remediation on #124), provider-rerun outcome evidence, organization review-pipeline congestion record, and refreshed merge-order queue guidance. Documentation evidence contracts were aligned to the same snapshot so the baseline, its dated markers, and the pinned exact-head rows cannot silently diverge. - Added `originweave_core::release_acceptance`, a deterministic fail-closed benchmark release-decision contract that requires one authoritative result for every mandatory suite, bounds explicit buyer-visible limitations, rejects duplicate limitation claim identities, and rejects non-canonical surrounding whitespace rather than normalizing it into an alternate claim spelling. - Refreshed the product and technical gap baseline with the 2026-08-24 live inventory: 158 open pull requests (44 ready, 114 draft), refreshed exact base/head evidence for the #208–#222 release, enterprise-approval, BAP, and WARC/PROV chains, the governance issue additions #212 and #215, and a required-check provider-failure record for the fail-closed Strix re-dispatches on #208/#218/#220. @@ -57,6 +104,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Updated active Browser Session doctoring to the immutable WebDriver BiDi Working Draft dated 9 September 2026 while keeping standards publication evidence separate from Chromium runtime qualification. - Aligned the hourly product-development branch-coverage toolchain and its one-shot materializer with the reviewed `nightly-2026-08-18` pin, and corrected the official Dependabot Rust-toolchain reference. - Separated logical origin authority from resolved network destination authority; an origin grant no longer implies permission to connect to every resolver result. - Separated resolved-address authorization from direct transport evidence; an approved IP now becomes a usable stream only after the operating system reports the exact requested IP and port. @@ -100,7 +148,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - DNS TLS identity requires an applicable subjectAltName and never falls back to Common Name; literal IPv4 and IPv6 origins require exact IP subjectAltName entries. - TLS uses an explicit immutable trust-root bundle and fixed verification time, and permits only TLS 1.2 and TLS 1.3. - TLS trust-bundle policy identifiers must contain at least one ASCII alphanumeric character; punctuation-only labels are rejected while `.`, `_`, `:`, and `-` remain permitted. -- TLS resumption, 0-RTT, secret extraction, key logging, client certificates, certificate compression, and dangerous custom verifier hooks are disabled in the first slice. +- TLS resumption, 0-RTT, secret extraction, key logging, client certificates, certificate compression, and dangerous custom verification are disabled in the first slice. - The operating-system peer is rechecked before, during, and after the deadline-bound TLS handshake. - ALPN selection is restricted to the caller's bounded allow-list, while absence is either explicitly recorded or rejected by policy. - Revocation is reported as not configured; the product makes no OCSP or CRL validation claim without supplied revocation evidence. diff --git a/crates/originweave-browser-session/src/browser_session.rs b/crates/originweave-browser-session/src/browser_session.rs new file mode 100644 index 000000000..007858bb7 --- /dev/null +++ b/crates/originweave-browser-session/src/browser_session.rs @@ -0,0 +1,2296 @@ +//! Browser Session lifecycle authority for OriginWeave. +//! +//! This crate owns the domain transition that turns a newly created disposable +//! browser isolation boundary into presentation-mutation authority. Driver identifiers +//! remain adapter data: naming a session or browsing context is never sufficient to mint authority. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +use std::collections::BTreeMap; +use std::fmt; +use std::sync::atomic::{AtomicU64, Ordering}; + +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +static NEXT_BROWSER_SESSION_INCARNATION: AtomicU64 = AtomicU64::new(1); +static ABANDONED_BOUND_SESSIONS: AtomicU64 = AtomicU64::new(0); + +/// Return the number of bound Browser Sessions abandoned with unresolved remote ownership. +/// +/// This is a process-local, non-I/O operability signal. It deliberately does not claim that remote +/// browser cleanup happened and is not a substitute for persisting exact recovery evidence before a +/// process exits. +#[must_use] +pub fn abandoned_bound_session_count() -> u64 { + ABANDONED_BOUND_SESSIONS.load(Ordering::Relaxed) +} + +/// Current lifecycle state of one Browser Session aggregate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserSessionState { + /// The session may create and own disposable contexts. + Active, + /// Every owned context was destroyed and the session was ended normally. + Ended, + /// The browser transport was lost while no ownership-recovery condition preceded it. + TransportLost, + /// Browser lifecycle ownership became uncertain and requires external reconciliation. + RecoveryRequired, +} + +/// Domain failure while changing Browser Session ownership state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserSessionError { + /// The requested transition requires an active Browser Session. + SessionNotActive, + /// No unused session-incarnation identity remains in this process. + IncarnationExhausted, + /// No unused monotonic authority or navigation generation remains, so issuance must fail closed. + EpochExhausted, + /// The disposable-context port proved that context creation failed without creating a boundary. + ContextCreationFailed, + /// Context creation may have created browser state that the aggregate cannot safely own or destroy. + ContextCreationUncertain, + /// The port returned a browsing-context identity already known to this aggregate. + DuplicateBrowsingContext, + /// The port returned an isolation identity already known to this aggregate. + DuplicateDisposableIsolation, + /// The requested context is not currently owned and active in this session. + ContextNotOwned, + /// The supplied authority belongs to another incarnation, isolation boundary, session, context, or epoch. + AuthorityMismatch, + /// The disposable-context port could not prove destruction of the owned isolation boundary. + ContextDestructionFailed, + /// Normal session end was requested while an owned or uncertain context remains. + ActiveContextRemains, +} + +/// Bounded failure from disposable-context creation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DisposableContextCreateError { + /// Creation failed and the adapter proved that no disposable boundary was created. + CreateFailedClean, + /// Creation failed after ownership may have changed. The optional identity is the exact + /// browser-issued isolation identity already known at the failure boundary, when available. + CreateFailedUncertain(Option), +} + +/// Bounded failure from disposable-context destruction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisposableContextDestroyError { + /// Destruction of an owned disposable context failed or could not be proven. + DestroyFailed, +} + +/// Compatibility error type for parsing a browser-issued disposable isolation identity. +/// +/// Browser Session preserves protocol text exactly and therefore does not currently emit either +/// variant. The result-shaped API remains stable for callers while protocol/runtime qualification +/// stays in the adapter boundary rather than being redefined as Browser Session lexical grammar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisposableIsolationIdError { + /// Reserved for compatibility with callers compiled against the earlier non-empty constraint. + Empty, + /// Reserved for compatibility with callers compiled against the earlier character constraint. + InvalidCharacter, +} + +/// Browser-issued identity for one disposable isolation boundary. +/// +/// This value is addressability, not mutation authority. A conforming adapter must return a value +/// that is non-aliasing for the live lifetime of the created boundary. A WebDriver BiDi adapter +/// should map this one-to-one to the specification-defined unique user-context identifier. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DisposableIsolationId(String); + +impl DisposableIsolationId { + /// Preserve one browser-issued isolation identity exactly as protocol text. + /// + /// Browser Session does not trim, normalize, reject empty text, reject control characters, or + /// impose an implementation-selected length limit. Any narrower runtime grammar must be proven + /// and enforced by the versioned adapter before this remote lifecycle address enters the domain. + pub fn parse(value: &str) -> Result { + Ok(Self(value.to_owned())) + } + + /// Return the exact browser-issued isolation identity. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Process-local, non-reused identity for one Browser Session aggregate incarnation. +/// +/// Presentation authority is intentionally non-serializable. A process restart therefore destroys +/// every outstanding authority value. Within one process this monotonic identity prevents a later +/// aggregate from revalidating an authority retained from an earlier aggregate that reused the same +/// transport/session and browser-issued context identifiers. The identity is also passed through the +/// lifecycle port so an adapter must scope its remote ownership mapping to the same incarnation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrowserSessionIncarnation(u64); + +impl BrowserSessionIncarnation { + /// Return the monotonic process-local incarnation value. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// Adapter result for one newly created disposable browser context. +/// +/// The isolation identity scopes the lifecycle boundary used for destruction; the browsing-context +/// identity addresses the independently navigable context inside that boundary. Neither field alone +/// is presentation-mutation authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DisposableContextHandle { + isolation: DisposableIsolationId, + browsing_context: BrowsingContextId, +} + +impl DisposableContextHandle { + /// Bind one validated isolation identity to its created browsing context. + #[must_use] + pub fn new(isolation: DisposableIsolationId, browsing_context: BrowsingContextId) -> Self { + Self { + isolation, + browsing_context, + } + } + + /// Return the non-aliasing disposable isolation identity. + #[must_use] + pub fn isolation(&self) -> &DisposableIsolationId { + &self.isolation + } + + /// Return the browsing-context address inside the disposable boundary. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.browsing_context + } +} + +/// Lossless evidence retained when browser lifecycle ownership is no longer proven. +/// +/// These values authorize no browser command. They exist only so a separately reviewed recovery +/// path can later reconcile exact remote identities instead of guessing from raw session/context ids. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BrowserSessionRecoveryEvidence { + /// A partial creation exposed a browser-issued isolation identity before completion became uncertain. + PartialCreationIsolation(DisposableIsolationId), + /// A create call returned a complete handle that aliased an already-owned context or isolation. + DuplicateAdapterHandle(DisposableContextHandle), + /// A complete create result could not be settled with the bound adapter after domain validation. + UnsettledAdapterHandle(DisposableContextHandle), + /// Destruction of this exact owned handle and validated authority epoch failed or could not be proven. + UnprovenDestruction { + /// Exact owned context whose remote boundary remains uncertain. + context: DisposableContextHandle, + /// Browser Session epoch validated immediately before destroy I/O. + context_epoch: BrowserContextEpoch, + }, + /// A recovery condition elsewhere in the session made this active owned handle uncertain. + RecoveryRequiredOwnedHandle(DisposableContextHandle), + /// Transport loss made this previously active owned handle uncertain. + TransportLossOwnedHandle(DisposableContextHandle), +} + +/// Exact create-attempt facts retained when one Browser Session creation transaction becomes uncertain. +/// +/// The legacy identity-oriented [`BrowserSessionRecoveryEvidence`] remains useful to recovery code that +/// reconciles remote handles. This companion evidence preserves the aggregate-issued attempt epoch and +/// completion disposition so two lifecycle facts with the same remote values cannot be collapsed into +/// one transaction. These values grant no browser command authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DisposableContextCreateRecoveryEvidence { + /// The adapter reported an uncertain create failure before a complete handle was available. + FailedUncertain { + /// Exact aggregate-issued epoch reserved for the failed create attempt. + attempt_epoch: BrowserContextEpoch, + /// Browser-issued isolation identity known at the failure boundary, when available. + isolation: Option, + }, + /// A complete candidate aliased already-owned browser state and was rejected by the aggregate. + DuplicateCandidate { + /// Exact aggregate-issued epoch reserved for the rejected create attempt. + attempt_epoch: BrowserContextEpoch, + /// Exact adapter-returned candidate associated with that attempt. + context: DisposableContextHandle, + }, + /// The adapter could not prove completion settlement for one exact create attempt. + CompletionUnsettled { + /// Exact aggregate-issued epoch reserved for the unsettled create attempt. + attempt_epoch: BrowserContextEpoch, + /// Aggregate decision whose delivery to the adapter could not be proven. + disposition: DisposableContextCreateDisposition, + /// Exact adapter-returned candidate associated with that attempt. + context: DisposableContextHandle, + }, +} + +/// Opaque Browser Session-issued request for one disposable-context creation attempt. +/// +/// There is deliberately no public constructor. A request is created only inside a +/// [`BoundBrowserSession`], after Browser Session has validated that the aggregate is active. Raw +/// session, incarnation, context, isolation, or adapter-selected identifiers cannot recreate it. +#[derive(Debug)] +pub struct DisposableContextCreateRequest { + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + attempt_epoch: BrowserContextEpoch, +} + +impl DisposableContextCreateRequest { + /// Return the Browser Session transport identity for adapter addressability. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the non-reused Browser Session incarnation for adapter lifecycle mapping. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + + /// Return the unique context epoch reserved for this create attempt. + #[must_use] + pub const fn attempt_epoch(&self) -> BrowserContextEpoch { + self.attempt_epoch + } +} + +/// Domain disposition for one completed disposable-context create attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisposableContextCreateDisposition { + /// The returned handle passed Browser Session ownership validation and may become authorizing. + Accepted, + /// The returned handle failed Browser Session ownership validation and must remain non-authorizing. + Rejected, +} + +/// Opaque Browser Session-issued completion for one exact create attempt. +/// +/// The adapter may stage remote protocol state while executing a create request, but it must not +/// promote that state into an authorizing binding until it receives an `Accepted` completion for the +/// same session incarnation and attempt epoch. `Rejected` candidates are recovery/quarantine evidence +/// only. There is deliberately no public constructor. +#[derive(Debug)] +pub struct DisposableContextCreateCompletion { + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + attempt_epoch: BrowserContextEpoch, + disposition: DisposableContextCreateDisposition, +} + +impl DisposableContextCreateCompletion { + /// Return the Browser Session transport identity for adapter correlation. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the non-reused Browser Session incarnation for adapter correlation. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + + /// Return the create-attempt epoch that this completion settles. + #[must_use] + pub const fn attempt_epoch(&self) -> BrowserContextEpoch { + self.attempt_epoch + } + + /// Return whether Browser Session accepted or rejected the created candidate. + #[must_use] + pub const fn disposition(&self) -> DisposableContextCreateDisposition { + self.disposition + } +} + +/// Failure while settling one exact create attempt with the bound lifecycle adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisposableContextCreateCompletionError { + /// The adapter could not prove that the exact pending create attempt reached the requested state. + CompletionFailed, +} + +/// Opaque Browser Session-issued request for destruction of one exact owned disposable context. +/// +/// There is deliberately no public constructor. The bound aggregate creates this request only after +/// validating current lifecycle custody. A caller cannot rebuild cleanup authority from raw browser +/// identifiers. The epoch is correlation evidence for the already-authorized request; it is not +/// independently sufficient to destroy state. +#[derive(Debug)] +pub struct DisposableContextDestroyRequest { + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + context: DisposableContextHandle, + context_epoch: BrowserContextEpoch, +} + +impl DisposableContextDestroyRequest { + /// Return the Browser Session transport identity for adapter addressability. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the non-reused Browser Session incarnation for adapter lifecycle mapping. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + + /// Return the exact domain handle whose remote isolation boundary must be destroyed. + #[must_use] + pub const fn context(&self) -> &DisposableContextHandle { + &self.context + } + + /// Return the exact Browser Session epoch validated before destroy I/O. + #[must_use] + pub const fn context_epoch(&self) -> BrowserContextEpoch { + self.context_epoch + } +} + +/// Port implemented by a reviewed browser adapter for disposable context lifecycle operations. +/// +/// The port never self-asserts an instance identifier. Instead, Browser Session consumes one concrete +/// port value into [`BoundBrowserSession`]. Public lifecycle methods then use only that owned port, so a +/// caller cannot swap a second adapter instance into create or destroy after binding. The port receives +/// only aggregate-issued request values with private construction paths. +/// +/// For WebDriver BiDi, creation should map the isolation identity one-to-one to the user-context +/// identifier returned by `browser.createUserContext`. [`DisposableContextCreateError::CreateFailedClean`] +/// is allowed only when the adapter proves that no disposable state was created. If a user-context +/// identity is already known when later creation or verification becomes uncertain, the adapter must +/// return it inside [`DisposableContextCreateError::CreateFailedUncertain`]. +/// +/// `destroy_disposable_context` must destroy the exact boundary carried by the supplied request and +/// return success only after destruction is proven. Reconstructing cleanup authority from raw driver +/// identifiers is forbidden, and a command acknowledgement alone is insufficient evidence. +pub trait DisposableContextPort { + /// Create one fresh disposable isolation boundary and browsing context for this authorized request. + fn create_disposable_context( + &mut self, + request: &DisposableContextCreateRequest, + ) -> Result; + + /// Settle the exact create attempt after Browser Session validates the returned domain handle. + /// + /// An adapter must keep a successful remote create result non-authorizing until this completion + /// accepts the matching attempt. A rejected attempt must remain non-authorizing and be retained + /// only for recovery/quarantine processing. + fn complete_disposable_context_creation( + &mut self, + completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError>; + + /// Destroy the exact disposable isolation boundary represented by this authorized request. + fn destroy_disposable_context( + &mut self, + request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError>; +} + +/// Opaque aggregate-authorized request for one purpose-bounded adapter operation. +/// +/// The caller supplies only the adapter-defined operation value. Browser Session validates the +/// accompanying presentation authority first and privately binds the operation to the exact owned +/// context and validated epoch before the consumed adapter can observe it. There is deliberately no +/// public constructor, and the epoch is correlation/provenance rather than standalone authority. +pub struct AuthorizedContextOperationRequest { + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + context: DisposableContextHandle, + context_epoch: BrowserContextEpoch, + operation: O, +} + +impl AuthorizedContextOperationRequest { + /// Return the Browser Session transport identity for adapter addressability. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the non-reused Browser Session incarnation for adapter lifecycle correlation. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + + /// Return the exact currently owned context validated before adapter I/O. + #[must_use] + pub const fn context(&self) -> &DisposableContextHandle { + &self.context + } + + /// Return the exact Browser Session epoch validated before adapter I/O. + #[must_use] + pub const fn context_epoch(&self) -> BrowserContextEpoch { + self.context_epoch + } + + /// Return the adapter-defined purpose-bounded operation payload. + #[must_use] + pub const fn operation(&self) -> &O { + &self.operation + } +} + +/// Failure from executing an aggregate-authorized operation through the consumed adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthorizedContextOperationError { + /// Browser Session rejected the authority before adapter I/O. + BrowserSession(BrowserSessionError), + /// The bound adapter attempted the authorized operation and returned its bounded failure. + Adapter(E), +} + +/// Adapter extension for purpose-bounded operations that must use the exact consumed adapter. +/// +/// Browser Session remains protocol-agnostic: the adapter owns the operation, output, and error +/// types. The wrapper only proves current ownership and routes the opaque request to the same concrete +/// adapter instance used for lifecycle creation and destruction. Implementations must not treat the +/// request as permission to mutate any other context. +pub trait AuthorizedContextOperationPort: DisposableContextPort { + /// Adapter-defined operation vocabulary, such as a reviewed BiDi presentation command. + type Operation; + /// Adapter-defined successful result. + type Output; + /// Adapter-defined bounded operation failure. + type Error; + + /// Execute one aggregate-authorized operation against the exact context carried by the request. + fn execute_authorized_context_operation( + &mut self, + request: &AuthorizedContextOperationRequest, + ) -> Result; +} + +/// Monotonic identity for one owned browsing-context authority epoch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrowserContextEpoch(u64); + +impl BrowserContextEpoch { + /// Return the internal monotonic epoch value. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// Opaque proof that Browser Session currently owns presentation mutation for one context epoch. +/// +/// The fields are private and no public constructor exists. A caller obtains this value only after +/// Browser Session has created a disposable boundary through its bound lifecycle port. Session +/// incarnation, isolation identity, context identity, and epoch must all still match before adapter I/O +/// is allowed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PresentationMutationAuthority { + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + isolation: DisposableIsolationId, + browsing_context: BrowsingContextId, + context_epoch: BrowserContextEpoch, +} + +impl PresentationMutationAuthority { + /// Return the Browser Session transport identity associated with this authority. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the Browser Session incarnation that minted this authority. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + + /// Return the owned disposable isolation identity. + #[must_use] + pub fn isolation(&self) -> &DisposableIsolationId { + &self.isolation + } + + /// Return the owned browsing-context identity. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.browsing_context + } + + /// Return the exact context epoch covered by this authority. + #[must_use] + pub const fn context_epoch(&self) -> BrowserContextEpoch { + self.context_epoch + } +} + +/// Opaque Browser Session-issued witness for one admitted navigation generation. +/// +/// Raw protocol navigation/context identifiers are evidence only. This value is minted only after the +/// aggregate validates the current session incarnation, owned context, and presentation epoch. Its +/// fields remain private so a caller cannot manufacture terminal or commit authority from raw BiDi +/// event data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationSettlementAuthority { + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + browsing_context: BrowsingContextId, + context_epoch: BrowserContextEpoch, + navigation_generation: u64, +} + +/// Typed negative terminal outcome for one admitted navigation witness. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavigationTerminationOutcome { + /// The browser reported navigation abortion. + Aborted, + /// The browser reported navigation failure. + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OwnedContextState { + Active, + Uncertain, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PresentationNavigationState { + Established, + Pending { + navigation_generation: u64, + committed: bool, + }, + Eligible, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OwnedContextRecord { + handle: DisposableContextHandle, + epoch: BrowserContextEpoch, + state: OwnedContextState, + presentation_navigation: PresentationNavigationState, +} + +/// Aggregate root for disposable browser-context lifecycle and presentation mutation authority. +pub struct BrowserSession { + id: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + state: BrowserSessionState, + transport_lost: bool, + next_epoch: u64, + next_navigation_generation: u64, + contexts: BTreeMap, + recovery_evidence: Vec, + create_recovery_evidence: Vec, +} + +impl fmt::Debug for BrowserSession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BrowserSession") + .field("browser_session", &self.id) + .field("incarnation", &self.incarnation) + .field("state", &self.state) + .field("transport_lost", &self.transport_lost) + .field("owned_context_count", &self.contexts.len()) + .field("recovery_evidence_count", &self.recovery_evidence.len()) + .field( + "create_recovery_evidence_count", + &self.create_recovery_evidence.len(), + ) + .finish() + } +} + +/// Browser Session composed with the one lifecycle-port instance allowed to mutate its remote state. +/// +/// Construction consumes both the aggregate and the concrete port. The port is not exposed mutably and +/// no public Browser Session lifecycle method accepts an arbitrary port parameter. This makes adapter +/// ownership structural rather than dependent on a caller-selected scalar or an adapter callback. +#[must_use = "destroy owned browser state and finish the session, or hand unresolved ownership to recovery"] +pub struct BoundBrowserSession

{ + session: BrowserSession, + port: P, +} + +impl

fmt::Debug for BoundBrowserSession

{ + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BoundBrowserSession") + .field("browser_session", &self.session.id) + .field("incarnation", &self.session.incarnation) + .field("state", &self.session.state) + .field("transport_lost", &self.session.transport_lost) + .field("owned_context_count", &self.session.contexts.len()) + .field( + "recovery_evidence_count", + &self.session.recovery_evidence.len(), + ) + .field( + "create_recovery_evidence_count", + &self.session.create_recovery_evidence.len(), + ) + .field("port", &"") + .finish() + } +} + +impl

Drop for BoundBrowserSession

{ + fn drop(&mut self) { + if self.session.has_unresolved_remote_ownership() { + let _ = ABANDONED_BOUND_SESSIONS.try_update( + Ordering::Relaxed, + Ordering::Relaxed, + |value| Some(value.saturating_add(1)), + ); + } + } +} + +impl BrowserSession { + /// Start an active Browser Session around an already validated transport session identity. + /// + /// A fresh process-local incarnation is allocated before any browser I/O. Exhaustion fails closed + /// rather than wrapping and making an older authority structurally valid again. + pub fn start(id: BrowserSessionId) -> Result { + Self::start_with_counter(id, &NEXT_BROWSER_SESSION_INCARNATION) + } + + fn start_with_counter( + id: BrowserSessionId, + counter: &AtomicU64, + ) -> Result { + let incarnation = allocate_incarnation(counter)?; + Ok(Self { + id, + incarnation, + state: BrowserSessionState::Active, + transport_lost: false, + next_epoch: 1, + next_navigation_generation: 1, + contexts: BTreeMap::new(), + recovery_evidence: Vec::new(), + create_recovery_evidence: Vec::new(), + }) + } + + /// Consume this aggregate and one concrete lifecycle port into a linear bound session. + /// + /// Binding invokes no adapter method. All subsequent create/destroy I/O is reachable only through + /// the owned port inside the returned wrapper. + pub fn bind_lifecycle_port(self, port: P) -> BoundBrowserSession

{ + BoundBrowserSession { + session: self, + port, + } + } + + /// Return this aggregate's browser-session transport identity. + #[must_use] + pub const fn id(&self) -> BrowserSessionId { + self.id + } + + /// Return this aggregate's non-reused process-local incarnation. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + + /// Return the current aggregate lifecycle state. + #[must_use] + pub const fn state(&self) -> BrowserSessionState { + self.state + } + + /// Report whether browser transport loss has been observed for this aggregate. + #[must_use] + pub const fn transport_is_lost(&self) -> bool { + self.transport_lost + } + + /// Return immutable recovery evidence retained after uncertain browser lifecycle outcomes. + #[must_use] + pub fn recovery_evidence(&self) -> &[BrowserSessionRecoveryEvidence] { + &self.recovery_evidence + } + + /// Return exact create-attempt recovery facts retained for transaction correlation. + #[must_use] + pub fn create_attempt_recovery_evidence(&self) -> &[DisposableContextCreateRecoveryEvidence] { + &self.create_recovery_evidence + } + + fn presentation_authority( + &self, + browsing_context: BrowsingContextId, + ) -> Result { + self.require_active()?; + let record = self + .contexts + .get(&browsing_context) + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + if record.presentation_navigation != PresentationNavigationState::Established { + return Err(BrowserSessionError::AuthorityMismatch); + } + Ok(Self::authority_for( + self.id, + self.incarnation, + &record.handle, + record.epoch, + )) + } + + /// Advance one active, presentation-authorized owned context to a new authority epoch. + pub fn advance_context_epoch( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + self.require_active()?; + let record = self + .contexts + .get_mut(&browsing_context) + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + if record.presentation_navigation != PresentationNavigationState::Established { + return Err(BrowserSessionError::AuthorityMismatch); + } + let next = reserve_epoch(&mut self.next_epoch)?; + record.epoch = next; + Ok(Self::authority_for( + self.id, + self.incarnation, + &record.handle, + next, + )) + } + + /// Record browser transport loss independently from ownership-recovery state. + /// + /// Returns `true` only for the first observed transport loss. If ownership was already uncertain, + /// `RecoveryRequired` remains the lifecycle state while the transport-loss fact is retained. + pub fn record_transport_loss(&mut self) -> bool { + if self.transport_lost || self.state == BrowserSessionState::Ended { + return false; + } + self.transport_lost = true; + if self.state == BrowserSessionState::Active { + self.recovery_evidence.extend( + self.contexts + .values() + .filter(|record| record.state == OwnedContextState::Active) + .map(|record| { + BrowserSessionRecoveryEvidence::TransportLossOwnedHandle( + record.handle.clone(), + ) + }), + ); + self.state = BrowserSessionState::TransportLost; + self.mark_active_contexts_uncertain(); + } + true + } + + /// End the Browser Session only after every owned context has proven destruction. + pub fn end(&mut self) -> Result<(), BrowserSessionError> { + self.require_active()?; + if !self.contexts.is_empty() { + return Err(BrowserSessionError::ActiveContextRemains); + } + self.state = BrowserSessionState::Ended; + Ok(()) + } + + fn create_disposable_context_with_port( + &mut self, + port: &mut P, + ) -> Result { + self.require_active()?; + let epoch = reserve_epoch(&mut self.next_epoch)?; + let request = DisposableContextCreateRequest { + browser_session: self.id, + incarnation: self.incarnation, + attempt_epoch: epoch, + }; + let handle = match port.create_disposable_context(&request) { + Ok(handle) => handle, + Err(DisposableContextCreateError::CreateFailedClean) => { + return Err(BrowserSessionError::ContextCreationFailed); + } + Err(DisposableContextCreateError::CreateFailedUncertain(isolation)) => { + self.create_recovery_evidence.push( + DisposableContextCreateRecoveryEvidence::FailedUncertain { + attempt_epoch: epoch, + isolation: isolation.clone(), + }, + ); + if let Some(isolation) = isolation { + self.recovery_evidence.push( + BrowserSessionRecoveryEvidence::PartialCreationIsolation(isolation), + ); + } + self.enter_recovery_required(); + return Err(BrowserSessionError::ContextCreationUncertain); + } + }; + + let duplicate_error = if self + .contexts + .values() + .any(|record| record.handle.isolation == handle.isolation) + { + Some(BrowserSessionError::DuplicateDisposableIsolation) + } else if self.contexts.contains_key(&handle.browsing_context) { + Some(BrowserSessionError::DuplicateBrowsingContext) + } else { + None + }; + + if let Some(error) = duplicate_error { + let completion = DisposableContextCreateCompletion { + browser_session: self.id, + incarnation: self.incarnation, + attempt_epoch: epoch, + disposition: DisposableContextCreateDisposition::Rejected, + }; + self.create_recovery_evidence.push( + DisposableContextCreateRecoveryEvidence::DuplicateCandidate { + attempt_epoch: epoch, + context: handle.clone(), + }, + ); + self.recovery_evidence + .push(BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( + handle.clone(), + )); + if port + .complete_disposable_context_creation(&completion) + .is_err() + { + self.create_recovery_evidence.push( + DisposableContextCreateRecoveryEvidence::CompletionUnsettled { + attempt_epoch: epoch, + disposition: DisposableContextCreateDisposition::Rejected, + context: handle.clone(), + }, + ); + self.recovery_evidence.push( + BrowserSessionRecoveryEvidence::UnsettledAdapterHandle(handle), + ); + self.enter_recovery_required(); + return Err(BrowserSessionError::ContextCreationUncertain); + } + self.enter_recovery_required(); + return Err(error); + } + + let completion = DisposableContextCreateCompletion { + browser_session: self.id, + incarnation: self.incarnation, + attempt_epoch: epoch, + disposition: DisposableContextCreateDisposition::Accepted, + }; + if port + .complete_disposable_context_creation(&completion) + .is_err() + { + self.create_recovery_evidence.push( + DisposableContextCreateRecoveryEvidence::CompletionUnsettled { + attempt_epoch: epoch, + disposition: DisposableContextCreateDisposition::Accepted, + context: handle.clone(), + }, + ); + self.recovery_evidence + .push(BrowserSessionRecoveryEvidence::UnsettledAdapterHandle( + handle, + )); + self.enter_recovery_required(); + return Err(BrowserSessionError::ContextCreationUncertain); + } + + let browsing_context = handle.browsing_context; + let authority = Self::authority_for(self.id, self.incarnation, &handle, epoch); + self.contexts.insert( + browsing_context, + OwnedContextRecord { + handle, + epoch, + state: OwnedContextState::Active, + presentation_navigation: PresentationNavigationState::Established, + }, + ); + Ok(authority) + } + + fn begin_observed_navigation( + &mut self, + incarnation: BrowserSessionIncarnation, + browsing_context: BrowsingContextId, + context_epoch: BrowserContextEpoch, + ) -> Result { + self.require_active()?; + if incarnation != self.incarnation { + return Err(BrowserSessionError::AuthorityMismatch); + } + let record = self + .contexts + .get_mut(&browsing_context) + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + if record.epoch != context_epoch { + return Err(BrowserSessionError::AuthorityMismatch); + } + let navigation_generation = + reserve_navigation_generation(&mut self.next_navigation_generation)?; + record.presentation_navigation = PresentationNavigationState::Pending { + navigation_generation, + committed: false, + }; + Ok(NavigationSettlementAuthority { + browser_session: self.id, + incarnation: self.incarnation, + browsing_context, + context_epoch, + navigation_generation, + }) + } + + fn current_pending_navigation_mut( + &mut self, + authority: &NavigationSettlementAuthority, + ) -> Result<&mut OwnedContextRecord, BrowserSessionError> { + self.require_active()?; + if authority.browser_session != self.id || authority.incarnation != self.incarnation { + return Err(BrowserSessionError::AuthorityMismatch); + } + let record = self + .contexts + .get_mut(&authority.browsing_context) + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + if record.epoch != authority.context_epoch { + return Err(BrowserSessionError::AuthorityMismatch); + } + match record.presentation_navigation { + PresentationNavigationState::Pending { + navigation_generation, + .. + } if navigation_generation == authority.navigation_generation => Ok(record), + _ => Err(BrowserSessionError::AuthorityMismatch), + } + } + + fn mark_observed_navigation_committed( + &mut self, + authority: &NavigationSettlementAuthority, + ) -> Result<(), BrowserSessionError> { + let record = self.current_pending_navigation_mut(authority)?; + match record.presentation_navigation { + PresentationNavigationState::Pending { + navigation_generation, + committed: false, + } => { + record.presentation_navigation = PresentationNavigationState::Pending { + navigation_generation, + committed: true, + }; + Ok(()) + } + PresentationNavigationState::Pending { + committed: true, .. + } => Err(BrowserSessionError::AuthorityMismatch), + _ => Err(BrowserSessionError::AuthorityMismatch), + } + } + + fn close_observed_navigation( + &mut self, + authority: &NavigationSettlementAuthority, + ) -> Result<(), BrowserSessionError> { + let record = self.current_pending_navigation_mut(authority)?; + record.presentation_navigation = PresentationNavigationState::Eligible; + Ok(()) + } + + fn reestablish_presentation_authority_for_context( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + self.require_active()?; + let record = self + .contexts + .get_mut(&browsing_context) + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + if record.presentation_navigation != PresentationNavigationState::Eligible { + return Err(BrowserSessionError::AuthorityMismatch); + } + let next = reserve_epoch(&mut self.next_epoch)?; + record.epoch = next; + record.presentation_navigation = PresentationNavigationState::Established; + Ok(Self::authority_for( + self.id, + self.incarnation, + &record.handle, + next, + )) + } + + fn destroy_disposable_context_with_port( + &mut self, + authority: &PresentationMutationAuthority, + port: &mut P, + ) -> Result<(), BrowserSessionError> { + let browser_session = self.id; + let incarnation = self.incarnation; + let browsing_context = authority.browsing_context; + let request = { + let record = self.context_for_authority_mut(authority)?; + DisposableContextDestroyRequest { + browser_session, + incarnation, + context: record.handle.clone(), + context_epoch: record.epoch, + } + }; + self.destroy_request_with_port(browsing_context, request, port) + } + + fn destroy_owned_disposable_context_with_port( + &mut self, + browsing_context: BrowsingContextId, + port: &mut P, + ) -> Result<(), BrowserSessionError> { + self.require_active()?; + let request = { + let record = self + .contexts + .get(&browsing_context) + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + DisposableContextDestroyRequest { + browser_session: self.id, + incarnation: self.incarnation, + context: record.handle.clone(), + context_epoch: record.epoch, + } + }; + self.destroy_request_with_port(browsing_context, request, port) + } + + fn destroy_request_with_port( + &mut self, + browsing_context: BrowsingContextId, + request: DisposableContextDestroyRequest, + port: &mut P, + ) -> Result<(), BrowserSessionError> { + match port.destroy_disposable_context(&request) { + Ok(()) => { + let _ = self.contexts.remove(&browsing_context); + Ok(()) + } + Err(DisposableContextDestroyError::DestroyFailed) => { + if let Some(record) = self.contexts.get_mut(&browsing_context) { + record.state = OwnedContextState::Uncertain; + } + self.recovery_evidence + .push(BrowserSessionRecoveryEvidence::UnprovenDestruction { + context: request.context, + context_epoch: request.context_epoch, + }); + self.enter_recovery_required(); + Err(BrowserSessionError::ContextDestructionFailed) + } + } + } + + fn require_active(&self) -> Result<(), BrowserSessionError> { + if self.state == BrowserSessionState::Active { + Ok(()) + } else { + Err(BrowserSessionError::SessionNotActive) + } + } + + fn authority_for( + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + handle: &DisposableContextHandle, + context_epoch: BrowserContextEpoch, + ) -> PresentationMutationAuthority { + PresentationMutationAuthority { + browser_session, + incarnation, + isolation: handle.isolation.clone(), + browsing_context: handle.browsing_context, + context_epoch, + } + } + + fn context_for_authority_mut( + &mut self, + authority: &PresentationMutationAuthority, + ) -> Result<&mut OwnedContextRecord, BrowserSessionError> { + self.require_active()?; + if authority.browser_session != self.id || authority.incarnation != self.incarnation { + return Err(BrowserSessionError::AuthorityMismatch); + } + let record = self + .contexts + .get_mut(&authority.browsing_context) + .filter(|record| record.state == OwnedContextState::Active) + .ok_or(BrowserSessionError::ContextNotOwned)?; + if record.epoch != authority.context_epoch + || record.handle.isolation != authority.isolation + || record.presentation_navigation != PresentationNavigationState::Established + { + return Err(BrowserSessionError::AuthorityMismatch); + } + Ok(record) + } + + fn enter_recovery_required(&mut self) { + let sibling_handles = self + .contexts + .values() + .filter(|record| record.state == OwnedContextState::Active) + .map(|record| record.handle.clone()) + .filter(|handle| { + !self.recovery_evidence.iter().any(|evidence| match evidence { + BrowserSessionRecoveryEvidence::PartialCreationIsolation(_) + | BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(_) + | BrowserSessionRecoveryEvidence::UnsettledAdapterHandle(_) => false, + BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(existing) + | BrowserSessionRecoveryEvidence::TransportLossOwnedHandle(existing) => { + existing == handle + } + BrowserSessionRecoveryEvidence::UnprovenDestruction { + context: existing, + .. + } => existing == handle, + }) + }) + .collect::>(); + self.recovery_evidence.extend( + sibling_handles + .into_iter() + .map(BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle), + ); + self.state = BrowserSessionState::RecoveryRequired; + self.mark_active_contexts_uncertain(); + } + + fn mark_active_contexts_uncertain(&mut self) { + for record in self.contexts.values_mut() { + if record.state == OwnedContextState::Active { + record.state = OwnedContextState::Uncertain; + } + } + } + + fn has_unresolved_remote_ownership(&self) -> bool { + matches!(self.state, BrowserSessionState::RecoveryRequired) + || self.contexts.values().any(|record| { + matches!(record.state, OwnedContextState::Active | OwnedContextState::Uncertain) + }) + } +} + +impl BoundBrowserSession

{ + /// Return the bound Browser Session for read-only policy and ACL validation. + #[must_use] + pub const fn browser_session(&self) -> &BrowserSession { + &self.session + } + + /// Create one disposable context through the exact port consumed when this session was bound. + pub fn create_disposable_context( + &mut self, + ) -> Result { + self.session + .create_disposable_context_with_port(&mut self.port) + } + + /// Return current presentation authority for an already-owned active context. + pub fn presentation_authority( + &self, + browsing_context: BrowsingContextId, + ) -> Result { + self.session.presentation_authority(browsing_context) + } + + /// Advance one presentation-authorized owned context to a new authority epoch. + pub fn advance_context_epoch( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + self.session.advance_context_epoch(browsing_context) + } + + /// Admit one browser-observed navigation start for the exact current ownership generation. + /// + /// Admission revokes presentation mutation immediately, performs no browser I/O, consumes no + /// presentation epoch, and returns the only witness accepted by later commit or terminal methods. + pub fn record_observed_navigation( + &mut self, + incarnation: BrowserSessionIncarnation, + browsing_context: BrowsingContextId, + context_epoch: BrowserContextEpoch, + ) -> Result { + self.session + .begin_observed_navigation(incarnation, browsing_context, context_epoch) + } + + /// Record the first qualified commit-progress event for one current navigation witness. + /// + /// Commit is non-terminal and does not create presentation re-establishment eligibility. + pub fn record_observed_navigation_committed( + &mut self, + authority: &NavigationSettlementAuthority, + ) -> Result<(), BrowserSessionError> { + self.session.mark_observed_navigation_committed(authority) + } + + /// Close one current navigation through a complete positive browser observation. + pub fn record_observed_navigation_settled( + &mut self, + authority: &NavigationSettlementAuthority, + ) -> Result<(), BrowserSessionError> { + self.session.close_observed_navigation(authority) + } + + /// Close one current navigation through an explicit typed negative browser outcome. + pub fn record_observed_navigation_terminated( + &mut self, + authority: &NavigationSettlementAuthority, + outcome: NavigationTerminationOutcome, + ) -> Result<(), BrowserSessionError> { + match outcome { + NavigationTerminationOutcome::Aborted | NavigationTerminationOutcome::Failed => { + self.session.close_observed_navigation(authority) + } + } + } + + /// Close one current navigation's liveness boundary when download start is observed. + /// + /// This does not claim file completion, persistence, scanning, egress authorization, or any + /// download-security outcome; those remain outside Browser Session. + pub fn record_observed_navigation_download_started( + &mut self, + authority: &NavigationSettlementAuthority, + ) -> Result<(), BrowserSessionError> { + self.session.close_observed_navigation(authority) + } + + /// Explicitly mint the next presentation authority after one qualified navigation closure. + /// + /// This is the only navigation path that consumes a new presentation epoch. The opportunity is + /// single-use; a newer navigation start supersedes any unused opportunity for the same context. + pub fn reestablish_presentation_authority( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + self.session + .reestablish_presentation_authority_for_context(browsing_context) + } + + /// Destroy the exact owned disposable boundary through current presentation authority. + pub fn destroy_disposable_context( + &mut self, + authority: &PresentationMutationAuthority, + ) -> Result<(), BrowserSessionError> { + self.session + .destroy_disposable_context_with_port(authority, &mut self.port) + } + + /// Destroy an owned disposable boundary through structural lifecycle custody. + /// + /// This cleanup path remains available while navigation has revoked presentation mutation, but it + /// can select only a context currently owned by this exact bound aggregate. It never re-establishes + /// presentation authority and consumes the retained browser-issued lifecycle handle on success. + pub fn destroy_owned_disposable_context( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result<(), BrowserSessionError> { + self.session + .destroy_owned_disposable_context_with_port(browsing_context, &mut self.port) + } + + /// Record browser transport loss without exposing mutable lifecycle-port access. + pub fn record_transport_loss(&mut self) -> bool { + self.session.record_transport_loss() + } + + /// End the Browser Session only after every owned context has proven destruction. + pub fn end(&mut self) -> Result<(), BrowserSessionError> { + self.session.end() + } + + /// Verify normal completion without relinquishing the exact bound lifecycle owner on failure. + /// + /// A rejected finish leaves the wrapper intact so the caller can destroy or reconcile outstanding + /// contexts and retry. After success the aggregate is `Ended`; dropping the wrapper is then inert. + pub fn finish(&mut self) -> Result<(), BrowserSessionError> { + self.session.end() + } + + /// Route one crate-internal recovery operation through the exact retained adapter. + /// + /// The callback is deliberately crate-private: external consumers never receive the raw adapter, + /// while recovery custody can bind one purpose-bounded request to the same adapter instance that + /// performed lifecycle creation and destruction. + pub(crate) fn dispatch_recovery_operation( + &mut self, + dispatch: impl FnOnce(&BrowserSession, &mut P) -> R, + ) -> R { + dispatch(&self.session, &mut self.port) + } +} + +impl BoundBrowserSession

{ + /// Execute one adapter-defined operation through the exact consumed adapter after authority validation. + /// + /// Browser Session validates session incarnation, isolation identity, browsing-context identity, + /// current presentation state, and epoch before the adapter receives the operation. Stale or foreign + /// authority therefore fails before adapter I/O, while the adapter-specific operation vocabulary + /// remains outside this domain. + pub fn execute_authorized_context_operation( + &mut self, + authority: &PresentationMutationAuthority, + operation: P::Operation, + ) -> Result> { + let browser_session = self.session.id; + let incarnation = self.session.incarnation; + let record = self + .session + .context_for_authority_mut(authority) + .map_err(AuthorizedContextOperationError::BrowserSession)?; + let context = record.handle.clone(); + let context_epoch = record.epoch; + let request = AuthorizedContextOperationRequest { + browser_session, + incarnation, + context, + context_epoch, + operation, + }; + self.port + .execute_authorized_context_operation(&request) + .map_err(AuthorizedContextOperationError::Adapter) + } +} + +fn reserve_epoch(next_epoch: &mut u64) -> Result { + let epoch = BrowserContextEpoch(*next_epoch); + *next_epoch = next_epoch + .checked_add(1) + .ok_or(BrowserSessionError::EpochExhausted)?; + Ok(epoch) +} + +fn reserve_navigation_generation(next_generation: &mut u64) -> Result { + let generation = *next_generation; + *next_generation = next_generation + .checked_add(1) + .ok_or(BrowserSessionError::EpochExhausted)?; + Ok(generation) +} + +fn allocate_incarnation( + counter: &AtomicU64, +) -> Result { + let value = counter + .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| { + current.checked_add(1) + }) + .map_err(|_| BrowserSessionError::IncarnationExhausted)?; + Ok(BrowserSessionIncarnation(value)) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + use std::collections::VecDeque; + + #[derive(Debug)] + struct TestPort { + handles: VecDeque, + create_error: Option, + fail_destroy: bool, + fail_completion: bool, + create_calls: usize, + destroy_calls: usize, + create_sessions: Vec, + create_incarnations: Vec, + create_attempts: Vec, + create_completions: Vec<( + BrowserSessionId, + BrowserSessionIncarnation, + BrowserContextEpoch, + DisposableContextCreateDisposition, + )>, + destroy_sessions: Vec, + destroy_incarnations: Vec, + destroyed_isolations: Vec, + } + + impl TestPort { + fn new(context: u64, isolation: &str) -> Self { + Self::with_handles(vec![DisposableContextHandle::new( + isolation_id(isolation), + context_id(context), + )]) + } + + fn with_handles(handles: Vec) -> Self { + Self { + handles: handles.into(), + create_error: None, + fail_destroy: false, + fail_completion: false, + create_calls: 0, + destroy_calls: 0, + create_sessions: Vec::new(), + create_incarnations: Vec::new(), + create_attempts: Vec::new(), + create_completions: Vec::new(), + destroy_sessions: Vec::new(), + destroy_incarnations: Vec::new(), + destroyed_isolations: Vec::new(), + } + } + } + + impl DisposableContextPort for TestPort { + fn create_disposable_context( + &mut self, + request: &DisposableContextCreateRequest, + ) -> Result { + self.create_calls += 1; + self.create_sessions.push(request.browser_session()); + self.create_incarnations.push(request.incarnation()); + self.create_attempts.push(request.attempt_epoch()); + match self.create_error.clone() { + Some(error) => Err(error), + None => Ok(self + .handles + .pop_front() + .expect("test must provide one handle per successful creation")), + } + } + + fn complete_disposable_context_creation( + &mut self, + completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + self.create_completions.push(( + completion.browser_session(), + completion.incarnation(), + completion.attempt_epoch(), + completion.disposition(), + )); + if self.fail_completion { + Err(DisposableContextCreateCompletionError::CompletionFailed) + } else { + Ok(()) + } + } + + fn destroy_disposable_context( + &mut self, + request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_calls += 1; + self.destroy_sessions.push(request.browser_session()); + self.destroy_incarnations.push(request.incarnation()); + self.destroyed_isolations + .push(request.context().isolation.clone()); + if self.fail_destroy { + Err(DisposableContextDestroyError::DestroyFailed) + } else { + Ok(()) + } + } + } + + fn session_id(value: u64) -> BrowserSessionId { + BrowserSessionId::new(value).expect("valid session id") + } + + fn context_id(value: u64) -> BrowsingContextId { + BrowsingContextId::new(value).expect("valid context id") + } + + fn isolation_id(value: &str) -> DisposableIsolationId { + DisposableIsolationId::parse(value).expect("protocol text representation is infallible") + } + + fn session(value: u64) -> BrowserSession { + BrowserSession::start(session_id(value)).expect("incarnation capacity") + } + + #[test] + fn isolation_identity_preserves_protocol_text_without_domain_grammar() { + let cases = [ + String::new(), + " user-context ".to_owned(), + "user\ncontext".to_owned(), + "x".repeat(4097), + "webdriver-user-context-10".to_owned(), + ]; + + for remote_user_context in cases { + let identity = DisposableIsolationId::parse(&remote_user_context) + .expect("protocol text representation is infallible"); + assert_eq!(identity.as_str(), remote_user_context); + } + + let valid = isolation_id("webdriver-user-context-10"); + let handle = DisposableContextHandle::new(valid.clone(), context_id(10)); + assert_eq!(handle.isolation(), &valid); + assert_eq!(handle.browsing_context(), context_id(10)); + } + + #[test] + fn bound_creation_is_the_only_raw_context_entry_to_authority() { + let raw_session = session(1); + assert_eq!(raw_session.id(), session_id(1)); + assert_ne!(raw_session.incarnation().value(), 0); + assert!(!raw_session.transport_is_lost()); + assert!(raw_session.recovery_evidence().is_empty()); + assert_eq!( + raw_session.presentation_authority(context_id(10)), + Err(BrowserSessionError::ContextNotOwned) + ); + let mut bound = raw_session.bind_lifecycle_port(TestPort::new(10, "isolation-10")); + let authority = bound + .create_disposable_context() + .expect("owned disposable context"); + assert_eq!(bound.port.create_sessions, vec![session_id(1)]); + assert_eq!( + bound.port.create_incarnations, + vec![bound.browser_session().incarnation()] + ); + assert_eq!(bound.port.create_attempts, vec![BrowserContextEpoch(1)]); + assert_eq!( + bound.port.create_completions, + vec![( + session_id(1), + bound.browser_session().incarnation(), + BrowserContextEpoch(1), + DisposableContextCreateDisposition::Accepted, + )] + ); + assert_eq!(authority.browser_session(), session_id(1)); + assert_eq!( + authority.incarnation(), + bound.browser_session().incarnation() + ); + assert_eq!(authority.isolation().as_str(), "isolation-10"); + assert_eq!(authority.browsing_context(), context_id(10)); + assert_eq!(authority.context_epoch().value(), 1); + assert_eq!(bound.presentation_authority(context_id(10)), Ok(authority)); + } + + #[test] + fn creation_failure_preserves_known_recovery_identity() { + let mut clean_port = TestPort::new(20, "isolation-20"); + clean_port.create_error = Some(DisposableContextCreateError::CreateFailedClean); + let mut clean = session(2).bind_lifecycle_port(clean_port); + assert_eq!( + clean.create_disposable_context(), + Err(BrowserSessionError::ContextCreationFailed) + ); + assert_eq!(clean.browser_session().state(), BrowserSessionState::Active); + clean.end().expect("clean failure can end"); + + let mut unknown_port = TestPort::new(210, "isolation-210"); + unknown_port.create_error = Some(DisposableContextCreateError::CreateFailedUncertain(None)); + let mut unknown = session(21).bind_lifecycle_port(unknown_port); + assert_eq!( + unknown.create_disposable_context(), + Err(BrowserSessionError::ContextCreationUncertain) + ); + assert!(unknown.browser_session().recovery_evidence().is_empty()); + assert_eq!(unknown.browser_session().create_attempt_recovery_evidence().len(), 1); + match &unknown.browser_session().create_attempt_recovery_evidence()[0] { + DisposableContextCreateRecoveryEvidence::FailedUncertain { + attempt_epoch, + isolation, + } => { + assert_eq!(attempt_epoch.value(), 1); + assert_eq!(isolation, &None); + } + other => panic!("unexpected recovery evidence: {other:?}"), + } + + let known = isolation_id("partial-user-context-211"); + let mut known_port = TestPort::new(211, "unused"); + known_port.create_error = Some(DisposableContextCreateError::CreateFailedUncertain(Some( + known.clone(), + ))); + let mut known_session = session(22).bind_lifecycle_port(known_port); + assert_eq!( + known_session.create_disposable_context(), + Err(BrowserSessionError::ContextCreationUncertain) + ); + assert_eq!( + known_session.browser_session().recovery_evidence(), + &[BrowserSessionRecoveryEvidence::PartialCreationIsolation( + known.clone() + )] + ); + assert_eq!(known_session.browser_session().create_attempt_recovery_evidence().len(), 1); + match &known_session.browser_session().create_attempt_recovery_evidence()[0] { + DisposableContextCreateRecoveryEvidence::FailedUncertain { + attempt_epoch, + isolation, + } => { + assert_eq!(attempt_epoch.value(), 1); + assert_eq!(isolation.as_ref(), Some(&known)); + } + other => panic!("unexpected recovery evidence: {other:?}"), + } + assert_eq!( + known_session.end(), + Err(BrowserSessionError::SessionNotActive) + ); + } + + #[test] + fn duplicate_adapter_output_preserves_offending_handle() { + let first_context_handle = + DisposableContextHandle::new(isolation_id("isolation-30-a"), context_id(30)); + let duplicate_context_handle = + DisposableContextHandle::new(isolation_id("isolation-30-b"), context_id(30)); + let context_port = TestPort::with_handles(vec![ + first_context_handle.clone(), + duplicate_context_handle.clone(), + ]); + let mut duplicate_context = session(3).bind_lifecycle_port(context_port); + duplicate_context + .create_disposable_context() + .expect("first owned context"); + assert_eq!( + duplicate_context.create_disposable_context(), + Err(BrowserSessionError::DuplicateBrowsingContext) + ); + assert_eq!( + duplicate_context.browser_session().recovery_evidence(), + &[ + BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(duplicate_context_handle), + BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(first_context_handle), + ] + ); + + let first_isolation_handle = + DisposableContextHandle::new(isolation_id("isolation-31"), context_id(310)); + let duplicate_isolation_handle = + DisposableContextHandle::new(isolation_id("isolation-31"), context_id(311)); + let isolation_port = TestPort::with_handles(vec![ + first_isolation_handle.clone(), + duplicate_isolation_handle.clone(), + ]); + let mut duplicate_isolation = session(31).bind_lifecycle_port(isolation_port); + duplicate_isolation + .create_disposable_context() + .expect("first owned isolation"); + assert_eq!( + duplicate_isolation.create_disposable_context(), + Err(BrowserSessionError::DuplicateDisposableIsolation) + ); + assert_eq!( + duplicate_isolation.browser_session().recovery_evidence(), + &[ + BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(duplicate_isolation_handle), + BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(first_isolation_handle), + ] + ); + } + + #[test] + fn create_completion_failure_preserves_non_authorizing_recovery_evidence() { + let expected = DisposableContextHandle::new(isolation_id("isolation-315"), context_id(315)); + let mut port = TestPort::with_handles(vec![expected.clone()]); + port.fail_completion = true; + let mut bound = session(315).bind_lifecycle_port(port); + + assert_eq!( + bound.create_disposable_context(), + Err(BrowserSessionError::ContextCreationUncertain) + ); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + assert_eq!( + bound.browser_session().recovery_evidence(), + &[BrowserSessionRecoveryEvidence::UnsettledAdapterHandle( + expected.clone() + )] + ); + assert_eq!(bound.browser_session().create_attempt_recovery_evidence().len(), 1); + match &bound.browser_session().create_attempt_recovery_evidence()[0] { + DisposableContextCreateRecoveryEvidence::CompletionUnsettled { + attempt_epoch, + disposition, + context, + } => { + assert_eq!(attempt_epoch.value(), 1); + assert_eq!(*disposition, DisposableContextCreateDisposition::Accepted); + assert_eq!(context, &expected); + } + other => panic!("unexpected recovery evidence: {other:?}"), + } + assert_eq!( + bound.port.create_completions[0].3, + DisposableContextCreateDisposition::Accepted + ); + assert_eq!( + bound.presentation_authority(context_id(315)), + Err(BrowserSessionError::SessionNotActive) + ); + } + + #[test] + fn rejected_create_completion_failure_preserves_duplicate_and_unsettled_evidence() { + let first = DisposableContextHandle::new(isolation_id("isolation-316-a"), context_id(316)); + let duplicate = + DisposableContextHandle::new(isolation_id("isolation-316-b"), context_id(316)); + let port = TestPort::with_handles(vec![first.clone(), duplicate.clone()]); + let mut bound = session(316).bind_lifecycle_port(port); + + bound + .create_disposable_context() + .expect("first candidate accepted"); + bound.port.fail_completion = true; + assert_eq!( + bound.create_disposable_context(), + Err(BrowserSessionError::ContextCreationUncertain) + ); + assert_eq!( + bound.browser_session().recovery_evidence(), + &[ + BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(duplicate.clone()), + BrowserSessionRecoveryEvidence::UnsettledAdapterHandle(duplicate.clone()), + BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(first), + ] + ); + assert_eq!(bound.browser_session().create_attempt_recovery_evidence().len(), 2); + match &bound.browser_session().create_attempt_recovery_evidence()[0] { + DisposableContextCreateRecoveryEvidence::DuplicateCandidate { + attempt_epoch, + context, + } => { + assert_eq!(attempt_epoch.value(), 2); + assert_eq!(context, &duplicate); + } + other => panic!("unexpected recovery evidence: {other:?}"), + } + match &bound.browser_session().create_attempt_recovery_evidence()[1] { + DisposableContextCreateRecoveryEvidence::CompletionUnsettled { + attempt_epoch, + disposition, + context, + } => { + assert_eq!(attempt_epoch.value(), 2); + assert_eq!(*disposition, DisposableContextCreateDisposition::Rejected); + assert_eq!(context, &duplicate); + } + other => panic!("unexpected recovery evidence: {other:?}"), + } + assert_eq!( + bound.port.create_completions[1].3, + DisposableContextCreateDisposition::Rejected + ); + } + + #[test] + fn bound_port_is_structural_and_not_swappable() { + let approved = TestPort::new(320, "isolation-320"); + let other = TestPort::new(321, "isolation-321"); + let mut bound = session(32).bind_lifecycle_port(approved); + let authority = bound + .create_disposable_context() + .expect("owned context uses consumed port"); + assert_eq!(other.create_calls, 0); + assert_eq!(other.destroy_calls, 0); + bound + .destroy_disposable_context(&authority) + .expect("same structurally bound port destroys context"); + assert_eq!(bound.port.create_calls, 1); + assert_eq!(bound.port.destroy_calls, 1); + } + + #[test] + fn epoch_exhaustion_prevents_creation_io() { + let mut bound = session(4).bind_lifecycle_port(TestPort::new(40, "isolation-40")); + bound.session.next_epoch = u64::MAX; + assert_eq!( + bound.create_disposable_context(), + Err(BrowserSessionError::EpochExhausted) + ); + assert_eq!(bound.port.create_calls, 0); + } + + #[test] + fn epoch_exhaustion_prevents_advance_mutation() { + let mut bound = session(41).bind_lifecycle_port(TestPort::new(410, "isolation-410")); + let authority = bound.create_disposable_context().expect("owned context"); + bound.session.next_epoch = u64::MAX; + assert_eq!( + bound.advance_context_epoch(context_id(410)), + Err(BrowserSessionError::EpochExhausted) + ); + assert_eq!(bound.presentation_authority(context_id(410)), Ok(authority)); + } + + #[test] + fn epoch_advance_invalidates_old_and_unknown_authority() { + let mut bound = session(5).bind_lifecycle_port(TestPort::new(50, "isolation-50")); + let old = bound.create_disposable_context().expect("owned context"); + assert_eq!( + bound.advance_context_epoch(context_id(51)), + Err(BrowserSessionError::ContextNotOwned) + ); + let new = bound + .advance_context_epoch(context_id(50)) + .expect("advanced epoch"); + assert_eq!(new.context_epoch().value(), 2); + assert_eq!( + bound.destroy_disposable_context(&old), + Err(BrowserSessionError::AuthorityMismatch) + ); + bound + .destroy_disposable_context(&new) + .expect("destroy current epoch"); + assert_eq!( + bound.port.destroy_incarnations, + vec![bound.browser_session().incarnation()] + ); + assert_eq!( + bound.presentation_authority(context_id(50)), + Err(BrowserSessionError::ContextNotOwned) + ); + assert_eq!( + bound.destroy_disposable_context(&new), + Err(BrowserSessionError::ContextNotOwned) + ); + } + + #[test] + fn cross_session_and_foreign_isolation_authority_fail_before_io() { + let mut owner = session(6).bind_lifecycle_port(TestPort::new(60, "isolation-60")); + let authority = owner.create_disposable_context().expect("owner context"); + + let mut foreign = session(7).bind_lifecycle_port(TestPort::new(60, "isolation-60")); + foreign + .create_disposable_context() + .expect("foreign context"); + assert_eq!( + foreign.destroy_disposable_context(&authority), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!(foreign.port.destroy_calls, 0); + + let forged = PresentationMutationAuthority { + browser_session: owner.browser_session().id(), + incarnation: owner.browser_session().incarnation(), + isolation: isolation_id("foreign-isolation"), + browsing_context: authority.browsing_context(), + context_epoch: authority.context_epoch(), + }; + assert_eq!( + owner.destroy_disposable_context(&forged), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!(owner.port.destroy_calls, 0); + } + + #[test] + fn sequential_incarnation_reuse_rejects_stale_authority() { + let shared_id = session_id(8); + let mut session_a = BrowserSession::start(shared_id) + .expect("A incarnation") + .bind_lifecycle_port(TestPort::new(80, "reused-user-context")); + let authority_a = session_a.create_disposable_context().expect("A context"); + session_a + .destroy_disposable_context(&authority_a) + .expect("A destroy"); + session_a.end().expect("A end"); + + let mut session_b = BrowserSession::start(shared_id) + .expect("B incarnation") + .bind_lifecycle_port(TestPort::new(80, "reused-user-context")); + let authority_b = session_b.create_disposable_context().expect("B context"); + assert_ne!( + session_a.browser_session().incarnation(), + session_b.browser_session().incarnation() + ); + assert_eq!( + session_b.destroy_disposable_context(&authority_a), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!(session_b.port.destroy_calls, 0); + session_b + .destroy_disposable_context(&authority_b) + .expect("B destroy"); + assert_eq!(session_b.port.destroy_calls, 1); + } + + #[test] + fn destroy_failure_retains_handle_and_transport_loss_orthogonally() { + let expected_handle = + DisposableContextHandle::new(isolation_id("isolation-90"), context_id(90)); + let mut port = TestPort::new(90, "isolation-90"); + port.fail_destroy = true; + let mut bound = session(9).bind_lifecycle_port(port); + let authority = bound.create_disposable_context().expect("owned context"); + let expected_epoch = authority.context_epoch(); + assert_eq!( + bound.destroy_disposable_context(&authority), + Err(BrowserSessionError::ContextDestructionFailed) + ); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + assert_eq!( + bound.browser_session().recovery_evidence(), + &[BrowserSessionRecoveryEvidence::UnprovenDestruction { + context: expected_handle, + context_epoch: expected_epoch, + }] + ); + assert!(!bound.browser_session().transport_is_lost()); + assert!(bound.record_transport_loss()); + assert!(bound.browser_session().transport_is_lost()); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + assert!(!bound.record_transport_loss()); + assert_eq!( + bound.create_disposable_context(), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!( + bound.presentation_authority(context_id(90)), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!( + bound.advance_context_epoch(context_id(90)), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!(bound.end(), Err(BrowserSessionError::SessionNotActive)); + } + + #[test] + fn transport_loss_invalidates_active_contexts_and_is_idempotent() { + let mut bound = session(10).bind_lifecycle_port(TestPort::new(100, "isolation-100")); + let authority = bound.create_disposable_context().expect("owned context"); + assert!(bound.record_transport_loss()); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::TransportLost + ); + assert!(bound.browser_session().transport_is_lost()); + assert!(!bound.record_transport_loss()); + assert_eq!( + bound.destroy_disposable_context(&authority), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!(bound.port.destroy_calls, 0); + } + + #[test] + fn normal_end_requires_proven_destruction_and_ignores_late_transport_report() { + let mut bound = session(11).bind_lifecycle_port(TestPort::new(110, "isolation-110")); + let authority = bound.create_disposable_context().expect("owned context"); + assert_eq!(bound.end(), Err(BrowserSessionError::ActiveContextRemains)); + bound + .destroy_disposable_context(&authority) + .expect("proven destruction"); + assert_eq!(bound.port.destroy_sessions, vec![session_id(11)]); + assert_eq!( + bound.port.destroyed_isolations, + vec![isolation_id("isolation-110")] + ); + bound.end().expect("normal end"); + assert_eq!(bound.browser_session().state(), BrowserSessionState::Ended); + assert!(!bound.record_transport_loss()); + assert_eq!(bound.end(), Err(BrowserSessionError::SessionNotActive)); + } + + #[test] + fn navigation_state_machine_separates_presentation_from_lifecycle_cleanup() { + let mut bound = session(13).bind_lifecycle_port(TestPort::new(130, "isolation-130")); + let initial = bound.create_disposable_context().expect("owned context"); + let calls_before_navigation = bound.port.destroy_calls; + let pending = bound + .record_observed_navigation( + initial.incarnation(), + initial.browsing_context(), + initial.context_epoch(), + ) + .expect("navigation start"); + assert_eq!( + bound.presentation_authority(initial.browsing_context()), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!( + bound.destroy_disposable_context(&initial), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!(bound.port.destroy_calls, calls_before_navigation); + bound + .record_observed_navigation_committed(&pending) + .expect("first commit progress"); + assert_eq!( + bound.record_observed_navigation_committed(&pending), + Err(BrowserSessionError::AuthorityMismatch) + ); + bound + .record_observed_navigation_settled(&pending) + .expect("positive terminal"); + assert_eq!( + bound.record_observed_navigation_download_started(&pending), + Err(BrowserSessionError::AuthorityMismatch) + ); + let fresh = bound + .reestablish_presentation_authority(initial.browsing_context()) + .expect("explicit re-establishment"); + assert_eq!(fresh.context_epoch().value(), initial.context_epoch().value() + 1); + assert_eq!( + bound.reestablish_presentation_authority(initial.browsing_context()), + Err(BrowserSessionError::AuthorityMismatch) + ); + bound + .destroy_owned_disposable_context(initial.browsing_context()) + .expect("bound lifecycle owner cleanup"); + assert_eq!(bound.port.destroy_calls, calls_before_navigation + 1); + assert_eq!( + bound.destroy_owned_disposable_context(initial.browsing_context()), + Err(BrowserSessionError::ContextNotOwned) + ); + } + + #[test] + fn navigation_generation_rejects_foreign_stale_and_invalid_evidence_without_epoch_spend() { + let handles = vec![ + DisposableContextHandle::new(isolation_id("isolation-140"), context_id(140)), + DisposableContextHandle::new(isolation_id("isolation-141"), context_id(141)), + ]; + let mut bound = session(14).bind_lifecycle_port(TestPort::with_handles(handles)); + let first = bound.create_disposable_context().expect("first context"); + let second = bound.create_disposable_context().expect("second context"); + assert_eq!( + bound.record_observed_navigation( + BrowserSessionIncarnation(first.incarnation().value() + 1), + first.browsing_context(), + first.context_epoch(), + ), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!( + bound.record_observed_navigation( + first.incarnation(), + context_id(999), + first.context_epoch(), + ), + Err(BrowserSessionError::ContextNotOwned) + ); + assert_eq!( + bound.record_observed_navigation( + first.incarnation(), + first.browsing_context(), + second.context_epoch(), + ), + Err(BrowserSessionError::AuthorityMismatch) + ); + let old_pending = bound + .record_observed_navigation( + first.incarnation(), + first.browsing_context(), + first.context_epoch(), + ) + .expect("first pending"); + let current_pending = bound + .record_observed_navigation( + first.incarnation(), + first.browsing_context(), + first.context_epoch(), + ) + .expect("superseding pending"); + assert_eq!( + bound.record_observed_navigation_settled(&old_pending), + Err(BrowserSessionError::AuthorityMismatch) + ); + bound + .record_observed_navigation_terminated( + ¤t_pending, + NavigationTerminationOutcome::Aborted, + ) + .expect("current negative terminal"); + let fresh = bound + .reestablish_presentation_authority(first.browsing_context()) + .expect("fresh authority"); + assert_eq!(fresh.context_epoch().value(), second.context_epoch().value() + 1); + assert_eq!( + bound.record_observed_navigation_terminated( + ¤t_pending, + NavigationTerminationOutcome::Failed, + ), + Err(BrowserSessionError::AuthorityMismatch) + ); + assert_eq!(bound.presentation_authority(first.browsing_context()), Ok(fresh)); + } + + #[test] + fn navigation_exhaustion_and_cleanup_failure_fail_closed_without_hidden_mutation() { + let mut bound = session(15).bind_lifecycle_port(TestPort::new(150, "isolation-150")); + let initial = bound.create_disposable_context().expect("owned context"); + bound.session.next_navigation_generation = u64::MAX; + assert_eq!( + bound.record_observed_navigation( + initial.incarnation(), + initial.browsing_context(), + initial.context_epoch(), + ), + Err(BrowserSessionError::EpochExhausted) + ); + assert_eq!( + bound.presentation_authority(initial.browsing_context()), + Ok(initial.clone()) + ); + bound.session.next_navigation_generation = 1; + let pending = bound + .record_observed_navigation( + initial.incarnation(), + initial.browsing_context(), + initial.context_epoch(), + ) + .expect("pending navigation"); + bound + .record_observed_navigation_download_started(&pending) + .expect("download liveness closure"); + bound.session.next_epoch = u64::MAX; + assert_eq!( + bound.reestablish_presentation_authority(initial.browsing_context()), + Err(BrowserSessionError::EpochExhausted) + ); + bound.port.fail_destroy = true; + assert_eq!( + bound.destroy_owned_disposable_context(initial.browsing_context()), + Err(BrowserSessionError::ContextDestructionFailed) + ); + assert_eq!(bound.browser_session().state(), BrowserSessionState::RecoveryRequired); + assert_eq!( + bound.destroy_owned_disposable_context(initial.browsing_context()), + Err(BrowserSessionError::SessionNotActive) + ); + assert_eq!( + bound.record_observed_navigation_settled(&pending), + Err(BrowserSessionError::SessionNotActive) + ); + } + + #[test] + fn incarnation_allocator_fails_closed_before_wrap() { + let counter = AtomicU64::new(u64::MAX); + let error = BrowserSession::start_with_counter(session_id(12), &counter) + .expect_err("incarnation allocation must fail closed before wrapping"); + assert_eq!(error, BrowserSessionError::IncarnationExhausted); + } +} + +impl BrowserSession { + fn settle_recovery_evidence_at( + &mut self, + index: usize, + expected: &BrowserSessionRecoveryEvidence, + ) -> bool { + if self.recovery_evidence.get(index) != Some(expected) { + return false; + } + let evidence = self.recovery_evidence.remove(index); + match &evidence { + BrowserSessionRecoveryEvidence::UnprovenDestruction { context, .. } + | BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(context) + | BrowserSessionRecoveryEvidence::TransportLossOwnedHandle(context) => { + self.retire_uncertain_owned_context(context); + } + BrowserSessionRecoveryEvidence::PartialCreationIsolation(_) + | BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(_) + | BrowserSessionRecoveryEvidence::UnsettledAdapterHandle(_) => {} + } + self.finish_recovery_if_resolved(); + true + } + + fn settle_create_attempt_recovery_evidence_at( + &mut self, + index: usize, + expected: &DisposableContextCreateRecoveryEvidence, + ) -> bool { + if self.create_recovery_evidence.get(index) != Some(expected) { + return false; + } + self.create_recovery_evidence.remove(index); + self.finish_recovery_if_resolved(); + true + } + + fn retire_uncertain_owned_context(&mut self, handle: &DisposableContextHandle) { + let browsing_context = handle.browsing_context(); + let should_remove = self.contexts.get(&browsing_context).is_some_and(|record| { + record.state == OwnedContextState::Uncertain && &record.handle == handle + }); + if should_remove { + let _ = self.contexts.remove(&browsing_context); + } + } + + fn finish_recovery_if_resolved(&mut self) { + if self.recovery_evidence.is_empty() + && self.create_recovery_evidence.is_empty() + && self.contexts.is_empty() + { + self.state = BrowserSessionState::Ended; + } + } +} + +impl

BoundBrowserSession

{ + pub(crate) fn settle_recovery_evidence_at( + &mut self, + index: usize, + expected: &BrowserSessionRecoveryEvidence, + ) -> bool { + self.session.settle_recovery_evidence_at(index, expected) + } + + pub(crate) fn settle_create_attempt_recovery_evidence_at( + &mut self, + index: usize, + expected: &DisposableContextCreateRecoveryEvidence, + ) -> bool { + self.session + .settle_create_attempt_recovery_evidence_at(index, expected) + } +} diff --git a/crates/originweave-browser-session/src/lib.rs b/crates/originweave-browser-session/src/lib.rs index 66f5753c5..0cc472312 100644 --- a/crates/originweave-browser-session/src/lib.rs +++ b/crates/originweave-browser-session/src/lib.rs @@ -1,1013 +1,32 @@ //! Browser Session lifecycle authority for OriginWeave. //! -//! This crate owns the domain transition that turns a newly created disposable -//! browser isolation boundary into presentation-mutation authority. Driver identifiers -//! remain adapter data: naming a session or browsing context is never sufficient to mint authority. +//! The aggregate implementation remains isolated from recovery custody. Public callers receive only +//! the narrow domain surface re-exported here; the concrete lifecycle adapter is never exposed. +//! +//! The read-only Browser Session projection must not become a capability-minting escape hatch. Only +//! the bound owner exposes the explicit presentation-authority surface. +//! +//! ```compile_fail +//! use originweave_browser_session::{BoundBrowserSession, DisposableContextPort}; +//! use originweave_core::BrowsingContextId; +//! +//! fn read_view_cannot_mint( +//! bound: &BoundBrowserSession

, +//! context: BrowsingContextId, +//! ) { +//! let _ = bound.browser_session().presentation_authority(context); +//! } +//! ``` #![forbid(unsafe_code)] #![deny(missing_docs)] -use std::collections::BTreeMap; -use std::sync::atomic::{AtomicU64, Ordering}; - -use originweave_core::{BrowserSessionId, BrowsingContextId}; - -static NEXT_BROWSER_SESSION_INCARNATION: AtomicU64 = AtomicU64::new(1); - -/// Current lifecycle state of one Browser Session aggregate. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BrowserSessionState { - /// The session may create and own disposable contexts. - Active, - /// Every owned context was destroyed and the session was ended normally. - Ended, - /// The browser transport was lost while no ownership-recovery condition preceded it. - TransportLost, - /// Browser lifecycle ownership became uncertain and requires external reconciliation. - RecoveryRequired, -} - -/// Domain failure while changing Browser Session ownership state. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BrowserSessionError { - /// The requested transition requires an active Browser Session. - SessionNotActive, - /// No unused session-incarnation identity remains in this process. - IncarnationExhausted, - /// No unused context epoch remains, so no new authority can be issued safely. - EpochExhausted, - /// The disposable-context port proved that context creation failed without creating a boundary. - ContextCreationFailed, - /// Context creation may have created browser state that the aggregate cannot safely own or destroy. - ContextCreationUncertain, - /// The port returned a browsing-context identity already known to this aggregate. - DuplicateBrowsingContext, - /// The port returned an isolation identity already known to this aggregate. - DuplicateDisposableIsolation, - /// The requested context is not currently owned and active in this session. - ContextNotOwned, - /// The supplied authority belongs to another incarnation, isolation boundary, session, context, or epoch. - AuthorityMismatch, - /// The disposable-context port could not prove destruction of the owned isolation boundary. - ContextDestructionFailed, - /// Normal session end was requested while an owned or uncertain context remains. - ActiveContextRemains, -} - -/// Bounded failure from disposable-context creation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DisposableContextCreateError { - /// Creation failed and the adapter proved that no disposable boundary was created. - CreateFailedClean, - /// Creation failed after ownership may have changed. The optional identity is the exact - /// browser-issued isolation identity already known at the failure boundary, when available. - CreateFailedUncertain(Option), -} - -/// Bounded failure from disposable-context destruction. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DisposableContextDestroyError { - /// Destruction of an owned disposable context failed or could not be proven. - DestroyFailed, -} - -/// Validation failure for a browser-issued disposable isolation identity. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DisposableIsolationIdError { - /// The identity is empty. - Empty, - /// The identity exceeds the bounded adapter evidence size. - TooLong, - /// The identity contains surrounding whitespace or control characters. - InvalidCharacter, -} - -/// Browser-issued identity for one disposable isolation boundary. -/// -/// This value is addressability, not mutation authority. A conforming adapter must return a value -/// that is non-aliasing for the live lifetime of the created boundary. A WebDriver BiDi adapter -/// should map this one-to-one to the specification-defined unique user-context identifier. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct DisposableIsolationId(String); - -impl DisposableIsolationId { - /// Parse one bounded browser-issued isolation identity. - pub fn parse(value: &str) -> Result { - if value.is_empty() { - return Err(DisposableIsolationIdError::Empty); - } - if value.len() > 4096 { - return Err(DisposableIsolationIdError::TooLong); - } - if value.trim() != value || value.chars().any(char::is_control) { - return Err(DisposableIsolationIdError::InvalidCharacter); - } - Ok(Self(value.to_owned())) - } - - /// Return the validated browser-issued isolation identity. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} - -/// Process-local, non-reused identity for one Browser Session aggregate incarnation. -/// -/// Presentation authority is intentionally non-serializable. A process restart therefore destroys -/// every outstanding authority value. Within one process this monotonic identity prevents a later -/// aggregate from revalidating an authority retained from an earlier aggregate that reused the same -/// transport/session and browser-issued context identifiers. The identity is also passed through the -/// lifecycle port so an adapter must scope its remote ownership mapping to the same incarnation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct BrowserSessionIncarnation(u64); - -impl BrowserSessionIncarnation { - /// Return the monotonic process-local incarnation value. - #[must_use] - pub const fn value(self) -> u64 { - self.0 - } -} - -/// Adapter result for one newly created disposable browser context. -/// -/// The isolation identity scopes the lifecycle boundary used for destruction; the browsing-context -/// identity addresses the independently navigable context inside that boundary. Neither field alone -/// is presentation-mutation authority. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DisposableContextHandle { - isolation: DisposableIsolationId, - browsing_context: BrowsingContextId, -} - -impl DisposableContextHandle { - /// Bind one validated isolation identity to its created browsing context. - #[must_use] - pub fn new(isolation: DisposableIsolationId, browsing_context: BrowsingContextId) -> Self { - Self { - isolation, - browsing_context, - } - } - - /// Return the non-aliasing disposable isolation identity. - #[must_use] - pub fn isolation(&self) -> &DisposableIsolationId { - &self.isolation - } - - /// Return the browsing-context address inside the disposable boundary. - #[must_use] - pub const fn browsing_context(&self) -> BrowsingContextId { - self.browsing_context - } -} - -/// Lossless evidence retained when browser lifecycle ownership is no longer proven. -/// -/// These values authorize no browser command. They exist only so a separately reviewed recovery -/// path can later reconcile exact remote identities instead of guessing from raw session/context ids. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum BrowserSessionRecoveryEvidence { - /// A partial creation exposed a browser-issued isolation identity before completion became uncertain. - PartialCreationIsolation(DisposableIsolationId), - /// A create call returned a complete handle that aliased an already-owned context or isolation. - DuplicateAdapterHandle(DisposableContextHandle), - /// Destruction of this exact owned handle failed or could not be proven. - UnprovenDestruction(DisposableContextHandle), -} - -/// Port implemented by a reviewed browser adapter for disposable context lifecycle operations. -/// -/// `incarnation` is domain-issued and must participate in the adapter's lifecycle mapping; ignoring it -/// would reintroduce sequential ABA aliasing. `create_disposable_context` must create a fresh isolation -/// boundary and context owned exclusively by the supplied Browser Session incarnation. For WebDriver -/// BiDi the isolation identity maps one-to-one to the user-context identifier returned by -/// `browser.createUserContext`. -/// -/// [`DisposableContextCreateError::CreateFailedClean`] is allowed only when the adapter proves that no -/// disposable state was created. If a user-context identity is already known when later creation or -/// verification becomes uncertain, the adapter must return it inside -/// [`DisposableContextCreateError::CreateFailedUncertain`]. -/// -/// `destroy_disposable_context` must destroy the exact boundary carried by the supplied handle and -/// return success only after destruction is proven. Reconstructing cleanup authority from raw driver -/// identifiers is forbidden, and a command acknowledgement alone is insufficient evidence. -pub trait DisposableContextPort { - /// Create one fresh disposable isolation boundary and browsing context for this incarnation. - fn create_disposable_context( - &mut self, - browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, - ) -> Result; - - /// Destroy the exact disposable isolation boundary represented by this handle and incarnation. - fn destroy_disposable_context( - &mut self, - browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, - context: &DisposableContextHandle, - ) -> Result<(), DisposableContextDestroyError>; -} - -/// Monotonic identity for one owned browsing-context authority epoch. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct BrowserContextEpoch(u64); - -impl BrowserContextEpoch { - /// Return the internal monotonic epoch value. - #[must_use] - pub const fn value(self) -> u64 { - self.0 - } -} - -/// Opaque proof that Browser Session currently owns presentation mutation for one context epoch. -/// -/// The fields are private and no public constructor exists. A caller obtains this value only after -/// Browser Session has created a disposable boundary through its lifecycle port. Session incarnation, -/// isolation identity, context identity, and epoch must all still match before adapter I/O is allowed. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PresentationMutationAuthority { - browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, - isolation: DisposableIsolationId, - browsing_context: BrowsingContextId, - context_epoch: BrowserContextEpoch, -} - -impl PresentationMutationAuthority { - /// Return the Browser Session transport identity associated with this authority. - #[must_use] - pub const fn browser_session(&self) -> BrowserSessionId { - self.browser_session - } - - /// Return the Browser Session incarnation that minted this authority. - #[must_use] - pub const fn incarnation(&self) -> BrowserSessionIncarnation { - self.incarnation - } - - /// Return the owned disposable isolation identity. - #[must_use] - pub fn isolation(&self) -> &DisposableIsolationId { - &self.isolation - } - - /// Return the owned browsing-context identity. - #[must_use] - pub const fn browsing_context(&self) -> BrowsingContextId { - self.browsing_context - } - - /// Return the exact context epoch covered by this authority. - #[must_use] - pub const fn context_epoch(&self) -> BrowserContextEpoch { - self.context_epoch - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum OwnedContextState { - Active, - Destroyed, - Uncertain, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OwnedContextRecord { - handle: DisposableContextHandle, - epoch: BrowserContextEpoch, - state: OwnedContextState, -} - -/// Aggregate root for disposable browser-context lifecycle and presentation mutation authority. -#[derive(Debug)] -pub struct BrowserSession { - id: BrowserSessionId, - incarnation: BrowserSessionIncarnation, - state: BrowserSessionState, - transport_lost: bool, - next_epoch: u64, - contexts: BTreeMap, - recovery_evidence: Vec, -} - -impl BrowserSession { - /// Start an active Browser Session around an already validated transport session identity. - /// - /// A fresh process-local incarnation is allocated before any browser I/O. Exhaustion fails closed - /// rather than wrapping and making an older authority structurally valid again. - pub fn start(id: BrowserSessionId) -> Result { - Self::start_with_counter(id, &NEXT_BROWSER_SESSION_INCARNATION) - } - - fn start_with_counter( - id: BrowserSessionId, - counter: &AtomicU64, - ) -> Result { - let incarnation = allocate_incarnation(counter)?; - Ok(Self { - id, - incarnation, - state: BrowserSessionState::Active, - transport_lost: false, - next_epoch: 1, - contexts: BTreeMap::new(), - recovery_evidence: Vec::new(), - }) - } - - /// Return this aggregate's browser-session transport identity. - #[must_use] - pub const fn id(&self) -> BrowserSessionId { - self.id - } - - /// Return this aggregate's non-reused process-local incarnation. - #[must_use] - pub const fn incarnation(&self) -> BrowserSessionIncarnation { - self.incarnation - } - - /// Return the current aggregate lifecycle state. - #[must_use] - pub const fn state(&self) -> BrowserSessionState { - self.state - } - - /// Report whether browser transport loss has been observed for this aggregate. - #[must_use] - pub const fn transport_is_lost(&self) -> bool { - self.transport_lost - } - - /// Return immutable recovery evidence retained after uncertain browser lifecycle outcomes. - #[must_use] - pub fn recovery_evidence(&self) -> &[BrowserSessionRecoveryEvidence] { - &self.recovery_evidence - } - - /// Create and register one disposable context, then mint authority for its first epoch. - pub fn create_disposable_context( - &mut self, - port: &mut P, - ) -> Result { - self.require_active()?; - let epoch = reserve_epoch(&mut self.next_epoch)?; - let handle = match port.create_disposable_context(self.id, self.incarnation) { - Ok(handle) => handle, - Err(DisposableContextCreateError::CreateFailedClean) => { - return Err(BrowserSessionError::ContextCreationFailed); - } - Err(DisposableContextCreateError::CreateFailedUncertain(isolation)) => { - if let Some(isolation) = isolation { - self.recovery_evidence.push( - BrowserSessionRecoveryEvidence::PartialCreationIsolation(isolation), - ); - } - self.enter_recovery_required(); - return Err(BrowserSessionError::ContextCreationUncertain); - } - }; - - if self - .contexts - .values() - .any(|record| record.handle.isolation == handle.isolation) - { - self.recovery_evidence - .push(BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( - handle, - )); - self.enter_recovery_required(); - return Err(BrowserSessionError::DuplicateDisposableIsolation); - } - if self.contexts.contains_key(&handle.browsing_context) { - self.recovery_evidence - .push(BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( - handle, - )); - self.enter_recovery_required(); - return Err(BrowserSessionError::DuplicateBrowsingContext); - } - - let browsing_context = handle.browsing_context; - let authority = Self::authority_for(self.id, self.incarnation, &handle, epoch); - self.contexts.insert( - browsing_context, - OwnedContextRecord { - handle, - epoch, - state: OwnedContextState::Active, - }, - ); - Ok(authority) - } - - /// Return current presentation authority for an already-owned active context. - pub fn presentation_authority( - &self, - browsing_context: BrowsingContextId, - ) -> Result { - self.require_active()?; - let record = self - .contexts - .get(&browsing_context) - .filter(|record| record.state == OwnedContextState::Active) - .ok_or(BrowserSessionError::ContextNotOwned)?; - Ok(Self::authority_for( - self.id, - self.incarnation, - &record.handle, - record.epoch, - )) - } - - /// Advance one active owned context to a new authority epoch. - pub fn advance_context_epoch( - &mut self, - browsing_context: BrowsingContextId, - ) -> Result { - self.require_active()?; - let browser_session = self.id; - let incarnation = self.incarnation; - let record = self - .contexts - .get_mut(&browsing_context) - .filter(|record| record.state == OwnedContextState::Active) - .ok_or(BrowserSessionError::ContextNotOwned)?; - let next = reserve_epoch(&mut self.next_epoch)?; - record.epoch = next; - Ok(Self::authority_for( - browser_session, - incarnation, - &record.handle, - next, - )) - } - - /// Destroy the disposable isolation boundary covered by the supplied exact-epoch authority. - pub fn destroy_disposable_context( - &mut self, - authority: &PresentationMutationAuthority, - port: &mut P, - ) -> Result<(), BrowserSessionError> { - let browser_session = self.id; - let incarnation = self.incarnation; - let record = self.context_for_authority_mut(authority)?; - let handle = record.handle.clone(); - match port.destroy_disposable_context(browser_session, incarnation, &handle) { - Ok(()) => { - record.state = OwnedContextState::Destroyed; - Ok(()) - } - Err(DisposableContextDestroyError::DestroyFailed) => { - record.state = OwnedContextState::Uncertain; - self.recovery_evidence - .push(BrowserSessionRecoveryEvidence::UnprovenDestruction(handle)); - self.enter_recovery_required(); - Err(BrowserSessionError::ContextDestructionFailed) - } - } - } - - /// Record browser transport loss independently from ownership-recovery state. - /// - /// Returns `true` only for the first observed transport loss. If ownership was already uncertain, - /// `RecoveryRequired` remains the lifecycle state while the transport-loss fact is retained. - pub fn record_transport_loss(&mut self) -> bool { - if self.transport_lost || self.state == BrowserSessionState::Ended { - return false; - } - self.transport_lost = true; - if self.state == BrowserSessionState::Active { - self.state = BrowserSessionState::TransportLost; - self.mark_active_contexts_uncertain(); - } - true - } - - /// End the Browser Session only after every owned context has proven destruction. - pub fn end(&mut self) -> Result<(), BrowserSessionError> { - self.require_active()?; - if self - .contexts - .values() - .any(|record| record.state != OwnedContextState::Destroyed) - { - return Err(BrowserSessionError::ActiveContextRemains); - } - self.state = BrowserSessionState::Ended; - Ok(()) - } - - fn require_active(&self) -> Result<(), BrowserSessionError> { - if self.state == BrowserSessionState::Active { - Ok(()) - } else { - Err(BrowserSessionError::SessionNotActive) - } - } - - fn authority_for( - browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, - handle: &DisposableContextHandle, - context_epoch: BrowserContextEpoch, - ) -> PresentationMutationAuthority { - PresentationMutationAuthority { - browser_session, - incarnation, - isolation: handle.isolation.clone(), - browsing_context: handle.browsing_context, - context_epoch, - } - } - - fn context_for_authority_mut( - &mut self, - authority: &PresentationMutationAuthority, - ) -> Result<&mut OwnedContextRecord, BrowserSessionError> { - self.require_active()?; - if authority.browser_session != self.id || authority.incarnation != self.incarnation { - return Err(BrowserSessionError::AuthorityMismatch); - } - let record = self - .contexts - .get_mut(&authority.browsing_context) - .filter(|record| record.state == OwnedContextState::Active) - .ok_or(BrowserSessionError::ContextNotOwned)?; - if record.epoch != authority.context_epoch || record.handle.isolation != authority.isolation - { - return Err(BrowserSessionError::AuthorityMismatch); - } - Ok(record) - } - - fn enter_recovery_required(&mut self) { - self.state = BrowserSessionState::RecoveryRequired; - self.mark_active_contexts_uncertain(); - } - - fn mark_active_contexts_uncertain(&mut self) { - for record in self.contexts.values_mut() { - if record.state == OwnedContextState::Active { - record.state = OwnedContextState::Uncertain; - } - } - } -} - -fn reserve_epoch(next_epoch: &mut u64) -> Result { - let epoch = BrowserContextEpoch(*next_epoch); - *next_epoch = next_epoch - .checked_add(1) - .ok_or(BrowserSessionError::EpochExhausted)?; - Ok(epoch) -} - -fn allocate_incarnation( - counter: &AtomicU64, -) -> Result { - let value = counter - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| { - current.checked_add(1) - }) - .map_err(|_| BrowserSessionError::IncarnationExhausted)?; - Ok(BrowserSessionIncarnation(value)) -} - -#[cfg(test)] -#[allow(clippy::expect_used)] -mod tests { - use super::*; - - #[derive(Debug)] - struct TestPort { - next_handle: DisposableContextHandle, - create_error: Option, - fail_destroy: bool, - create_calls: usize, - destroy_calls: usize, - create_incarnations: Vec, - destroy_incarnations: Vec, - destroyed_isolations: Vec, - } - - impl TestPort { - fn new(context: u64, isolation: &str) -> Self { - Self { - next_handle: DisposableContextHandle::new( - isolation_id(isolation), - context_id(context), - ), - create_error: None, - fail_destroy: false, - create_calls: 0, - destroy_calls: 0, - create_incarnations: Vec::new(), - destroy_incarnations: Vec::new(), - destroyed_isolations: Vec::new(), - } - } - } - - impl DisposableContextPort for TestPort { - fn create_disposable_context( - &mut self, - _browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, - ) -> Result { - self.create_calls += 1; - self.create_incarnations.push(incarnation); - match self.create_error.clone() { - Some(error) => Err(error), - None => Ok(self.next_handle.clone()), - } - } - - fn destroy_disposable_context( - &mut self, - _browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, - context: &DisposableContextHandle, - ) -> Result<(), DisposableContextDestroyError> { - self.destroy_calls += 1; - self.destroy_incarnations.push(incarnation); - self.destroyed_isolations.push(context.isolation.clone()); - if self.fail_destroy { - Err(DisposableContextDestroyError::DestroyFailed) - } else { - Ok(()) - } - } - } - - fn session_id(value: u64) -> BrowserSessionId { - BrowserSessionId::new(value).expect("valid session id") - } - - fn context_id(value: u64) -> BrowsingContextId { - BrowsingContextId::new(value).expect("valid context id") - } - - fn isolation_id(value: &str) -> DisposableIsolationId { - DisposableIsolationId::parse(value).expect("valid isolation id") - } - - fn session(value: u64) -> BrowserSession { - BrowserSession::start(session_id(value)).expect("incarnation capacity") - } - - #[test] - fn isolation_identity_validation_is_bounded() { - assert_eq!( - DisposableIsolationId::parse(""), - Err(DisposableIsolationIdError::Empty) - ); - assert_eq!( - DisposableIsolationId::parse(&"x".repeat(4097)), - Err(DisposableIsolationIdError::TooLong) - ); - assert_eq!( - DisposableIsolationId::parse(" user-context "), - Err(DisposableIsolationIdError::InvalidCharacter) - ); - assert_eq!( - DisposableIsolationId::parse("user\ncontext"), - Err(DisposableIsolationIdError::InvalidCharacter) - ); - let valid = isolation_id("webdriver-user-context-10"); - assert_eq!(valid.as_str(), "webdriver-user-context-10"); - let handle = DisposableContextHandle::new(valid.clone(), context_id(10)); - assert_eq!(handle.isolation(), &valid); - assert_eq!(handle.browsing_context(), context_id(10)); - } - - #[test] - fn disposable_creation_is_the_only_raw_context_entry_to_authority() { - let mut session = session(1); - let mut port = TestPort::new(10, "isolation-10"); - assert_eq!(session.id(), session_id(1)); - assert_ne!(session.incarnation().value(), 0); - assert!(!session.transport_is_lost()); - assert!(session.recovery_evidence().is_empty()); - assert_eq!( - session.presentation_authority(context_id(10)), - Err(BrowserSessionError::ContextNotOwned) - ); - let authority = session - .create_disposable_context(&mut port) - .expect("owned disposable context"); - assert_eq!(port.create_incarnations, vec![session.incarnation()]); - assert_eq!(authority.browser_session(), session_id(1)); - assert_eq!(authority.incarnation(), session.incarnation()); - assert_eq!(authority.isolation().as_str(), "isolation-10"); - assert_eq!(authority.browsing_context(), context_id(10)); - assert_eq!(authority.context_epoch().value(), 1); - assert_eq!( - session.presentation_authority(context_id(10)), - Ok(authority) - ); - } - - #[test] - fn creation_failure_preserves_known_recovery_identity() { - let mut clean_session = session(2); - let mut clean_port = TestPort::new(20, "isolation-20"); - clean_port.create_error = Some(DisposableContextCreateError::CreateFailedClean); - assert_eq!( - clean_session.create_disposable_context(&mut clean_port), - Err(BrowserSessionError::ContextCreationFailed) - ); - assert_eq!(clean_session.state(), BrowserSessionState::Active); - clean_session.end().expect("clean failure can end"); - - let mut unknown_session = session(21); - let mut unknown_port = TestPort::new(210, "isolation-210"); - unknown_port.create_error = Some(DisposableContextCreateError::CreateFailedUncertain(None)); - assert_eq!( - unknown_session.create_disposable_context(&mut unknown_port), - Err(BrowserSessionError::ContextCreationUncertain) - ); - assert!(unknown_session.recovery_evidence().is_empty()); - - let known = isolation_id("partial-user-context-211"); - let mut known_session = session(22); - let mut known_port = TestPort::new(211, "unused"); - known_port.create_error = Some(DisposableContextCreateError::CreateFailedUncertain(Some( - known.clone(), - ))); - assert_eq!( - known_session.create_disposable_context(&mut known_port), - Err(BrowserSessionError::ContextCreationUncertain) - ); - assert_eq!( - known_session.recovery_evidence(), - &[BrowserSessionRecoveryEvidence::PartialCreationIsolation( - known - )] - ); - assert_eq!( - known_session.end(), - Err(BrowserSessionError::SessionNotActive) - ); - } - - #[test] - fn duplicate_adapter_output_preserves_offending_handle() { - let mut duplicate_context_session = session(3); - let mut first_context_port = TestPort::new(30, "isolation-30-a"); - duplicate_context_session - .create_disposable_context(&mut first_context_port) - .expect("first owned context"); - let duplicate_context_handle = - DisposableContextHandle::new(isolation_id("isolation-30-b"), context_id(30)); - let mut duplicate_context_port = TestPort::new(30, "isolation-30-b"); - assert_eq!( - duplicate_context_session.create_disposable_context(&mut duplicate_context_port), - Err(BrowserSessionError::DuplicateBrowsingContext) - ); - assert_eq!( - duplicate_context_session.recovery_evidence(), - &[BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( - duplicate_context_handle - )] - ); - - let mut duplicate_isolation_session = session(31); - let mut first_isolation_port = TestPort::new(310, "isolation-31"); - duplicate_isolation_session - .create_disposable_context(&mut first_isolation_port) - .expect("first owned isolation"); - let duplicate_isolation_handle = - DisposableContextHandle::new(isolation_id("isolation-31"), context_id(311)); - let mut duplicate_isolation_port = TestPort::new(311, "isolation-31"); - assert_eq!( - duplicate_isolation_session.create_disposable_context(&mut duplicate_isolation_port), - Err(BrowserSessionError::DuplicateDisposableIsolation) - ); - assert_eq!( - duplicate_isolation_session.recovery_evidence(), - &[BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( - duplicate_isolation_handle - )] - ); - } - - #[test] - fn epoch_exhaustion_prevents_creation_io() { - let mut exhausted_session = session(4); - exhausted_session.next_epoch = u64::MAX; - let mut unused_port = TestPort::new(40, "isolation-40"); - assert_eq!( - exhausted_session.create_disposable_context(&mut unused_port), - Err(BrowserSessionError::EpochExhausted) - ); - assert_eq!(unused_port.create_calls, 0); - } - - #[test] - fn epoch_exhaustion_prevents_advance_mutation() { - let mut exhausted_session = session(41); - let mut port = TestPort::new(410, "isolation-410"); - let authority = exhausted_session - .create_disposable_context(&mut port) - .expect("owned context"); - exhausted_session.next_epoch = u64::MAX; - assert_eq!( - exhausted_session.advance_context_epoch(context_id(410)), - Err(BrowserSessionError::EpochExhausted) - ); - assert_eq!( - exhausted_session.presentation_authority(context_id(410)), - Ok(authority) - ); - } - - #[test] - fn epoch_advance_invalidates_old_and_unknown_authority() { - let mut session = session(5); - let mut port = TestPort::new(50, "isolation-50"); - let old = session - .create_disposable_context(&mut port) - .expect("owned context"); - assert_eq!( - session.advance_context_epoch(context_id(51)), - Err(BrowserSessionError::ContextNotOwned) - ); - let new = session - .advance_context_epoch(context_id(50)) - .expect("advanced epoch"); - assert_eq!(new.context_epoch().value(), 2); - assert_eq!( - session.destroy_disposable_context(&old, &mut port), - Err(BrowserSessionError::AuthorityMismatch) - ); - session - .destroy_disposable_context(&new, &mut port) - .expect("destroy current epoch"); - assert_eq!(port.destroy_incarnations, vec![session.incarnation()]); - assert_eq!( - session.presentation_authority(context_id(50)), - Err(BrowserSessionError::ContextNotOwned) - ); - assert_eq!( - session.destroy_disposable_context(&new, &mut port), - Err(BrowserSessionError::ContextNotOwned) - ); - } - - #[test] - fn cross_session_and_foreign_isolation_authority_fail_before_io() { - let mut owner = session(6); - let mut owner_port = TestPort::new(60, "isolation-60"); - let authority = owner - .create_disposable_context(&mut owner_port) - .expect("owner context"); - - let mut foreign = session(7); - let mut foreign_port = TestPort::new(60, "isolation-60"); - foreign - .create_disposable_context(&mut foreign_port) - .expect("foreign context"); - assert_eq!( - foreign.destroy_disposable_context(&authority, &mut foreign_port), - Err(BrowserSessionError::AuthorityMismatch) - ); - assert_eq!(foreign_port.destroy_calls, 0); - - let forged = PresentationMutationAuthority { - browser_session: owner.id(), - incarnation: owner.incarnation(), - isolation: isolation_id("foreign-isolation"), - browsing_context: authority.browsing_context(), - context_epoch: authority.context_epoch(), - }; - assert_eq!( - owner.destroy_disposable_context(&forged, &mut owner_port), - Err(BrowserSessionError::AuthorityMismatch) - ); - assert_eq!(owner_port.destroy_calls, 0); - } - - #[test] - fn sequential_incarnation_reuse_rejects_stale_authority() { - let shared_id = session_id(8); - let mut session_a = BrowserSession::start(shared_id).expect("A incarnation"); - let mut port_a = TestPort::new(80, "reused-user-context"); - let authority_a = session_a - .create_disposable_context(&mut port_a) - .expect("A context"); - session_a - .destroy_disposable_context(&authority_a, &mut port_a) - .expect("A destroy"); - session_a.end().expect("A end"); - - let mut session_b = BrowserSession::start(shared_id).expect("B incarnation"); - let mut port_b = TestPort::new(80, "reused-user-context"); - let authority_b = session_b - .create_disposable_context(&mut port_b) - .expect("B context"); - assert_ne!(session_a.incarnation(), session_b.incarnation()); - assert_eq!( - session_b.destroy_disposable_context(&authority_a, &mut port_b), - Err(BrowserSessionError::AuthorityMismatch) - ); - assert_eq!(port_b.destroy_calls, 0); - session_b - .destroy_disposable_context(&authority_b, &mut port_b) - .expect("B destroy"); - assert_eq!(port_b.destroy_calls, 1); - } - - #[test] - fn destroy_failure_retains_handle_and_transport_loss_orthogonally() { - let mut session = session(9); - let mut port = TestPort::new(90, "isolation-90"); - let authority = session - .create_disposable_context(&mut port) - .expect("owned context"); - let expected_handle = - DisposableContextHandle::new(isolation_id("isolation-90"), context_id(90)); - port.fail_destroy = true; - assert_eq!( - session.destroy_disposable_context(&authority, &mut port), - Err(BrowserSessionError::ContextDestructionFailed) - ); - assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); - assert_eq!( - session.recovery_evidence(), - &[BrowserSessionRecoveryEvidence::UnprovenDestruction( - expected_handle - )] - ); - assert!(!session.transport_is_lost()); - assert!(session.record_transport_loss()); - assert!(session.transport_is_lost()); - assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); - assert!(!session.record_transport_loss()); - assert_eq!( - session.create_disposable_context(&mut port), - Err(BrowserSessionError::SessionNotActive) - ); - assert_eq!( - session.presentation_authority(context_id(90)), - Err(BrowserSessionError::SessionNotActive) - ); - assert_eq!( - session.advance_context_epoch(context_id(90)), - Err(BrowserSessionError::SessionNotActive) - ); - assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); - } - - #[test] - fn transport_loss_invalidates_active_contexts_and_is_idempotent() { - let mut session = session(10); - let mut port = TestPort::new(100, "isolation-100"); - let authority = session - .create_disposable_context(&mut port) - .expect("owned context"); - assert!(session.record_transport_loss()); - assert_eq!(session.state(), BrowserSessionState::TransportLost); - assert!(session.transport_is_lost()); - assert!(!session.record_transport_loss()); - assert_eq!( - session.destroy_disposable_context(&authority, &mut port), - Err(BrowserSessionError::SessionNotActive) - ); - assert_eq!(port.destroy_calls, 0); - } - - #[test] - fn normal_end_requires_proven_destruction_and_ignores_late_transport_report() { - let mut session = session(11); - let mut port = TestPort::new(110, "isolation-110"); - let authority = session - .create_disposable_context(&mut port) - .expect("owned context"); - assert_eq!( - session.end(), - Err(BrowserSessionError::ActiveContextRemains) - ); - session - .destroy_disposable_context(&authority, &mut port) - .expect("proven destruction"); - session.end().expect("normal end"); - assert_eq!(session.state(), BrowserSessionState::Ended); - assert!(!session.record_transport_loss()); - assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); - } +mod browser_session; +mod recovery; - #[test] - fn incarnation_allocator_fails_closed_before_wrap() { - let counter = AtomicU64::new(u64::MAX); - let error = BrowserSession::start_with_counter(session_id(12), &counter) - .expect_err("incarnation allocation must fail closed before wrapping"); - assert_eq!(error, BrowserSessionError::IncarnationExhausted); - } -} +pub use browser_session::*; +pub use recovery::{ + BoundBrowserSessionRecovery, RecoveryContextOperationError, RecoveryContextOperationPort, + RecoveryContextOperationRequest, RecoveryFact, RecoverySettlementError, RecoverySettlementPort, + RecoverySettlementRequest, +}; diff --git a/crates/originweave-browser-session/src/recovery.rs b/crates/originweave-browser-session/src/recovery.rs new file mode 100644 index 000000000..0923cbd70 --- /dev/null +++ b/crates/originweave-browser-session/src/recovery.rs @@ -0,0 +1,517 @@ +use originweave_core::BrowserSessionId; + +use crate::browser_session::{ + BoundBrowserSession, BrowserSessionIncarnation, BrowserSessionRecoveryEvidence, + BrowserSessionState, DisposableContextCreateRecoveryEvidence, DisposableContextPort, +}; + +/// Opaque recovery-custody request for one purpose-bounded adapter operation. +/// +/// Construction is private to [`BoundBrowserSessionRecovery`]. The request snapshots the exact +/// Browser Session identity, incarnation, unresolved lifecycle state, and exactly one current +/// non-authorizing recovery fact immediately before adapter I/O. Sibling recovery facts are not +/// disclosed to the operation adapter. None of these fields independently grant ordinary creation, +/// presentation mutation, or destruction authority. +pub struct RecoveryContextOperationRequest { + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + state: BrowserSessionState, + recovery_evidence: Option, + create_attempt_recovery_evidence: Option, + operation: O, +} + +impl RecoveryContextOperationRequest { + /// Return the exact browser-session transport identity under recovery custody. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the exact process-local Browser Session incarnation under recovery custody. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + + /// Return the unresolved Browser Session lifecycle state captured before adapter I/O. + #[must_use] + pub const fn state(&self) -> BrowserSessionState { + self.state + } + + /// Return the selected identity-oriented recovery fact, when this operation targets that ledger. + #[must_use] + pub const fn recovery_evidence(&self) -> Option<&BrowserSessionRecoveryEvidence> { + self.recovery_evidence.as_ref() + } + + /// Return the selected create-attempt recovery fact, when this operation targets that ledger. + #[must_use] + pub const fn create_attempt_recovery_evidence( + &self, + ) -> Option<&DisposableContextCreateRecoveryEvidence> { + self.create_attempt_recovery_evidence.as_ref() + } + + /// Return the adapter-defined purpose-bounded recovery operation. + #[must_use] + pub const fn operation(&self) -> &O { + &self.operation + } +} + +/// Adapter extension for purpose-bounded recovery operations on the exact consumed lifecycle port. +/// +/// Browser Session remains protocol-agnostic. Implementations own their operation, output, and error +/// vocabularies, while recovery custody supplies only the one current recovery fact selected by a +/// Browser Session-issued [`RecoveryFact`] and routes the request through the same concrete adapter +/// instance consumed by [`BoundBrowserSession`]. A successful adapter return is not itself proof that +/// remote ownership was reconciled or destroyed. +pub trait RecoveryContextOperationPort: DisposableContextPort { + /// Adapter-defined recovery operation vocabulary. + type Operation; + /// Adapter-defined successful result. + type Output; + /// Adapter-defined bounded recovery failure. + type Error; + + /// Execute one purpose-bounded recovery operation using the exact recovery-custody request. + fn execute_recovery_context_operation( + &mut self, + request: &RecoveryContextOperationRequest, + ) -> Result; +} + +/// Failure from executing one recovery operation through the exact retained adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecoveryContextOperationError { + /// Recovery custody has already reached a terminal state with no unresolved command purpose. + RecoveryClosed, + /// The selected fact belongs to another Browser Session or process-local incarnation. + AuthorityMismatch, + /// The selected fact was issued for an older ledger revision or no longer addresses a current fact. + StaleFact, + /// The retained adapter attempted the recovery operation and returned its bounded failure. + Adapter(E), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecoveryFactLedger { + Recovery, + CreateAttempt, +} + +/// Opaque Browser Session-issued handle for one current recovery fact. +/// +/// The fields are private. A caller can retain or replay this value, but cannot construct a different +/// session, ledger, index, or revision. Every successful settlement advances the recovery-ledger +/// revision, which invalidates all handles issued before that mutation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RecoveryFact { + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + revision: u64, + ledger: RecoveryFactLedger, + index: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecoveryFactValidationError { + AuthorityMismatch, + StaleFact, +} + +/// Opaque request used by the retained adapter to verify one independently qualified recovery proof. +/// +/// Browser Session chooses exactly one current recovery fact before adapter I/O. The request carries +/// that fact's immutable domain evidence together with the adapter-defined proof. Neither the proof nor +/// the evidence is command authority, and a successful verifier return is committed by Browser Session +/// only after the opaque fact handle has already passed session/incarnation/revision validation. +pub struct RecoverySettlementRequest

{ + browser_session: BrowserSessionId, + incarnation: BrowserSessionIncarnation, + state: BrowserSessionState, + recovery_evidence: Option, + create_attempt_recovery_evidence: Option, + proof: P, +} + +impl

RecoverySettlementRequest

{ + /// Return the exact browser-session transport identity under recovery custody. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the exact process-local Browser Session incarnation under recovery custody. + #[must_use] + pub const fn incarnation(&self) -> BrowserSessionIncarnation { + self.incarnation + } + + /// Return the unresolved Browser Session lifecycle state captured before proof verification. + #[must_use] + pub const fn state(&self) -> BrowserSessionState { + self.state + } + + /// Return the selected identity-oriented recovery fact, when this request targets that ledger. + #[must_use] + pub const fn recovery_evidence(&self) -> Option<&BrowserSessionRecoveryEvidence> { + self.recovery_evidence.as_ref() + } + + /// Return the selected create-attempt recovery fact, when this request targets that ledger. + #[must_use] + pub const fn create_attempt_recovery_evidence( + &self, + ) -> Option<&DisposableContextCreateRecoveryEvidence> { + self.create_attempt_recovery_evidence.as_ref() + } + + /// Return the adapter-defined independent reconciliation proof. + #[must_use] + pub const fn proof(&self) -> &P { + &self.proof + } +} + +/// Adapter extension that qualifies independent evidence for one exact recovery fact. +/// +/// The adapter owns protocol-specific proof semantics such as WebDriver BiDi event correlation and +/// remote-liveness qualification. Browser Session owns the selected domain fact and commits its +/// retirement only after this verifier succeeds. Command acknowledgement alone must not be modeled as +/// proof merely because it was returned by the retained adapter. +pub trait RecoverySettlementPort: DisposableContextPort { + /// Adapter-defined proof type for independent reconciliation evidence. + type Proof; + /// Adapter-defined proof-verification failure. + type Error; + + /// Verify that the supplied proof reconciles exactly the domain fact carried by the request. + fn verify_recovery_settlement( + &mut self, + request: &RecoverySettlementRequest, + ) -> Result<(), Self::Error>; +} + +/// Failure while settling one Browser Session-issued recovery fact. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecoverySettlementError { + /// The fact belongs to another Browser Session or process-local incarnation. + AuthorityMismatch, + /// The fact was issued for an older ledger revision or no longer addresses the current ledger. + StaleFact, + /// The monotonic recovery-ledger revision cannot advance without wrapping. + RevisionExhausted, + /// The retained adapter rejected the independently supplied proof. + Adapter(E), +} + +/// Recovery-only custody of a Browser Session and its exact consumed lifecycle adapter. +/// +/// This wrapper is obtained only by consuming a bound session that has already entered +/// [`BrowserSessionState::RecoveryRequired`] or entered [`BrowserSessionState::TransportLost`] while +/// retaining unresolved remote-ownership evidence. It exposes lifecycle state, exact non-authorizing +/// recovery evidence, and exact-fact-bounded recovery-operation and recovery-settlement paths through +/// the retained adapter. It deliberately provides none of the ordinary create, presentation-authority, +/// epoch-advance, destroy, authorized-operation, or normal-finish methods, and it does not expose the +/// inner [`BoundBrowserSession`] or concrete port. +/// +/// Ordinary context creation is not available from recovery custody: +/// +/// ```compile_fail +/// use originweave_browser_session::{BoundBrowserSessionRecovery, DisposableContextPort}; +/// fn forbidden(mut recovery: BoundBrowserSessionRecovery

) { +/// let _ = recovery.create_disposable_context(); +/// } +/// ``` +/// +/// Presentation-authority lookup is not available directly or through an inner Browser Session: +/// +/// ```compile_fail +/// use originweave_browser_session::{BoundBrowserSessionRecovery, DisposableContextPort}; +/// use originweave_core::BrowsingContextId; +/// fn forbidden( +/// recovery: BoundBrowserSessionRecovery

, +/// context: BrowsingContextId, +/// ) { +/// let _ = recovery.presentation_authority(context); +/// } +/// ``` +/// +/// ```compile_fail +/// use originweave_browser_session::{BoundBrowserSessionRecovery, DisposableContextPort}; +/// use originweave_core::BrowsingContextId; +/// fn forbidden( +/// recovery: BoundBrowserSessionRecovery

, +/// context: BrowsingContextId, +/// ) { +/// let _ = recovery.browser_session().presentation_authority(context); +/// } +/// ``` +/// +/// Context-epoch advancement is not available from recovery custody: +/// +/// ```compile_fail +/// use originweave_browser_session::{BoundBrowserSessionRecovery, DisposableContextPort}; +/// use originweave_core::BrowsingContextId; +/// fn forbidden( +/// mut recovery: BoundBrowserSessionRecovery

, +/// context: BrowsingContextId, +/// ) { +/// let _ = recovery.advance_context_epoch(context); +/// } +/// ``` +/// +/// Ordinary destruction is not available from recovery custody: +/// +/// ```compile_fail +/// use originweave_browser_session::{ +/// BoundBrowserSessionRecovery, DisposableContextPort, PresentationMutationAuthority, +/// }; +/// fn forbidden( +/// mut recovery: BoundBrowserSessionRecovery

, +/// authority: PresentationMutationAuthority, +/// ) { +/// let _ = recovery.destroy_disposable_context(&authority); +/// } +/// ``` +/// +/// Normal session completion is not available from recovery custody: +/// +/// ```compile_fail +/// use originweave_browser_session::{BoundBrowserSessionRecovery, DisposableContextPort}; +/// fn forbidden(mut recovery: BoundBrowserSessionRecovery

) { +/// let _ = recovery.finish(); +/// } +/// ``` +/// +/// Dropping this wrapper performs no browser I/O. The contained [`BoundBrowserSession`] retains its +/// existing abandonment accounting when unresolved remote ownership is finally dropped. +#[must_use = "persist or reconcile unresolved Browser Session ownership before dropping recovery custody"] +pub struct BoundBrowserSessionRecovery

{ + bound: BoundBrowserSession

, + revision: u64, +} + +impl BoundBrowserSession

{ + /// Consume an unresolved bound session into recovery-only custody without adapter I/O. + /// + /// `RecoveryRequired` always represents unresolved lifecycle ownership. `TransportLost` permits + /// handoff only when the aggregate retained exact non-authorizing recovery evidence; transport loss + /// by itself, before any remote ownership or after proven destruction, must not create an alternate + /// adapter-operation capability. Active, ended, and ownership-clean transport-lost sessions are + /// returned unchanged. + pub fn into_recovery(self) -> Result, Self> { + let state = self.browser_session().state(); + let has_recovery_evidence = !self.browser_session().recovery_evidence().is_empty() + || !self + .browser_session() + .create_attempt_recovery_evidence() + .is_empty(); + match state { + BrowserSessionState::RecoveryRequired => Ok(BoundBrowserSessionRecovery { + bound: self, + revision: 1, + }), + BrowserSessionState::TransportLost if has_recovery_evidence => { + Ok(BoundBrowserSessionRecovery { + bound: self, + revision: 1, + }) + } + BrowserSessionState::Active + | BrowserSessionState::Ended + | BrowserSessionState::TransportLost => Err(self), + } + } +} + +impl BoundBrowserSessionRecovery

{ + /// Return the lifecycle state captured by the unresolved Browser Session aggregate. + #[must_use] + pub const fn state(&self) -> BrowserSessionState { + self.bound.browser_session().state() + } + + /// Return exact non-authorizing ownership-recovery evidence. + #[must_use] + pub fn recovery_evidence(&self) -> &[BrowserSessionRecoveryEvidence] { + self.bound.browser_session().recovery_evidence() + } + + /// Return exact non-authorizing create-attempt recovery provenance. + #[must_use] + pub fn create_attempt_recovery_evidence(&self) -> &[DisposableContextCreateRecoveryEvidence] { + self.bound + .browser_session() + .create_attempt_recovery_evidence() + } + + /// Issue an opaque handle for one current identity-oriented recovery fact. + #[must_use] + pub fn recovery_fact(&self, index: usize) -> Option { + self.recovery_evidence().get(index)?; + Some(RecoveryFact { + browser_session: self.bound.browser_session().id(), + incarnation: self.bound.browser_session().incarnation(), + revision: self.revision, + ledger: RecoveryFactLedger::Recovery, + index, + }) + } + + /// Issue an opaque handle for one current create-attempt recovery fact. + #[must_use] + pub fn create_attempt_recovery_fact(&self, index: usize) -> Option { + self.create_attempt_recovery_evidence().get(index)?; + Some(RecoveryFact { + browser_session: self.bound.browser_session().id(), + incarnation: self.bound.browser_session().incarnation(), + revision: self.revision, + ledger: RecoveryFactLedger::CreateAttempt, + index, + }) + } + + fn select_recovery_fact( + &self, + fact: RecoveryFact, + ) -> Result< + ( + Option, + Option, + ), + RecoveryFactValidationError, + > { + let session = self.bound.browser_session(); + if fact.browser_session != session.id() || fact.incarnation != session.incarnation() { + return Err(RecoveryFactValidationError::AuthorityMismatch); + } + if fact.revision != self.revision { + return Err(RecoveryFactValidationError::StaleFact); + } + match fact.ledger { + RecoveryFactLedger::Recovery => { + let evidence = self + .recovery_evidence() + .get(fact.index) + .cloned() + .ok_or(RecoveryFactValidationError::StaleFact)?; + Ok((Some(evidence), None)) + } + RecoveryFactLedger::CreateAttempt => { + let evidence = self + .create_attempt_recovery_evidence() + .get(fact.index) + .cloned() + .ok_or(RecoveryFactValidationError::StaleFact)?; + Ok((None, Some(evidence))) + } + } + } +} + +impl BoundBrowserSessionRecovery

{ + /// Execute one purpose-bounded recovery operation through the exact retained lifecycle adapter. + /// + /// A current Browser Session-issued recovery fact must be supplied. Session/incarnation, current + /// ledger revision, and current fact address are validated before adapter I/O. The adapter receives + /// only that selected fact rather than the sibling recovery ledgers. Adapter success or failure + /// leaves Browser Session state and evidence unchanged; protocol-specific code must provide a + /// separate reviewed reconciliation proof before uncertainty can be retired. Once exact-fact + /// settlement closes recovery to `Ended`, later operations fail before retained-adapter I/O. + pub fn execute_recovery_context_operation( + &mut self, + fact: RecoveryFact, + operation: P::Operation, + ) -> Result> { + if !matches!( + self.state(), + BrowserSessionState::RecoveryRequired | BrowserSessionState::TransportLost + ) { + return Err(RecoveryContextOperationError::RecoveryClosed); + } + let (recovery_evidence, create_attempt_recovery_evidence) = self + .select_recovery_fact(fact) + .map_err(|error| match error { + RecoveryFactValidationError::AuthorityMismatch => { + RecoveryContextOperationError::AuthorityMismatch + } + RecoveryFactValidationError::StaleFact => RecoveryContextOperationError::StaleFact, + })?; + self.bound.dispatch_recovery_operation(|session, port| { + let request = RecoveryContextOperationRequest { + browser_session: session.id(), + incarnation: session.incarnation(), + state: session.state(), + recovery_evidence, + create_attempt_recovery_evidence, + operation, + }; + port.execute_recovery_context_operation(&request) + .map_err(RecoveryContextOperationError::Adapter) + }) + } +} + +impl BoundBrowserSessionRecovery

{ + /// Verify and retire exactly one current recovery fact through the retained adapter. + /// + /// Session/incarnation, current ledger revision, and current fact address are validated before the + /// adapter sees the proof. A successful proof retires only the selected fact, advances the ledger + /// revision, and therefore invalidates every handle issued before the mutation. Adapter failure or + /// any pre-I/O validation failure leaves Browser Session state and both recovery ledgers unchanged. + pub fn settle_recovery_fact( + &mut self, + fact: RecoveryFact, + proof: P::Proof, + ) -> Result<(), RecoverySettlementError> { + let (recovery_evidence, create_attempt_recovery_evidence) = self + .select_recovery_fact(fact) + .map_err(|error| match error { + RecoveryFactValidationError::AuthorityMismatch => { + RecoverySettlementError::AuthorityMismatch + } + RecoveryFactValidationError::StaleFact => RecoverySettlementError::StaleFact, + })?; + let next_revision = self + .revision + .checked_add(1) + .ok_or(RecoverySettlementError::RevisionExhausted)?; + let session = self.bound.browser_session(); + let request = RecoverySettlementRequest { + browser_session: session.id(), + incarnation: session.incarnation(), + state: session.state(), + recovery_evidence: recovery_evidence.clone(), + create_attempt_recovery_evidence: create_attempt_recovery_evidence.clone(), + proof, + }; + self.bound + .dispatch_recovery_operation(|_, port| port.verify_recovery_settlement(&request)) + .map_err(RecoverySettlementError::Adapter)?; + + let retired = match fact.ledger { + RecoveryFactLedger::Recovery => self.bound.settle_recovery_evidence_at( + fact.index, + recovery_evidence + .as_ref() + .ok_or(RecoverySettlementError::StaleFact)?, + ), + RecoveryFactLedger::CreateAttempt => self.bound.settle_create_attempt_recovery_evidence_at( + fact.index, + create_attempt_recovery_evidence + .as_ref() + .ok_or(RecoverySettlementError::StaleFact)?, + ), + }; + if !retired { + return Err(RecoverySettlementError::StaleFact); + } + self.revision = next_revision; + Ok(()) + } +} diff --git a/crates/originweave-browser-session/tests/authorized_context_operation.rs b/crates/originweave-browser-session/tests/authorized_context_operation.rs new file mode 100644 index 000000000..86438445c --- /dev/null +++ b/crates/originweave-browser-session/tests/authorized_context_operation.rs @@ -0,0 +1,171 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use originweave_browser_session::{ + AuthorizedContextOperationError, AuthorizedContextOperationPort, + AuthorizedContextOperationRequest, BrowserSession, BrowserSessionError, + BrowserSessionIncarnation, DisposableContextCreateCompletion, + DisposableContextCreateCompletionError, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +struct OperationPort { + handle: Option, + operation_calls: Rc>, + observed_operations: Rc>>, + observed_sessions: Rc>>, + observed_incarnations: Rc>>, + observed_epochs: Rc>>, + fail_operation: Rc>, +} + +impl DisposableContextPort for OperationPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.handle + .take() + .ok_or(DisposableContextCreateError::CreateFailedClean) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Ok(()) + } +} + +impl AuthorizedContextOperationPort for OperationPort { + type Operation = &'static str; + type Output = BrowsingContextId; + type Error = (); + + fn execute_authorized_context_operation( + &mut self, + request: &AuthorizedContextOperationRequest, + ) -> Result { + self.operation_calls.set(self.operation_calls.get() + 1); + self.observed_operations + .borrow_mut() + .push(*request.operation()); + self.observed_sessions + .borrow_mut() + .push(request.browser_session()); + self.observed_incarnations + .borrow_mut() + .push(request.incarnation()); + self.observed_epochs + .borrow_mut() + .push(request.context_epoch().value()); + if self.fail_operation.get() { + Err(()) + } else { + Ok(request.context().browsing_context()) + } + } +} + +#[test] +fn authorized_operation_uses_exact_bound_port_and_rejects_stale_authority_before_io() { + let operation_calls = Rc::new(Cell::new(0)); + let observed_operations = Rc::new(RefCell::new(Vec::new())); + let observed_sessions = Rc::new(RefCell::new(Vec::new())); + let observed_incarnations = Rc::new(RefCell::new(Vec::new())); + let observed_epochs = Rc::new(RefCell::new(Vec::new())); + let fail_operation = Rc::new(Cell::new(false)); + let context = BrowsingContextId::new(503).expect("valid browsing context"); + let port = OperationPort { + handle: Some(DisposableContextHandle::new( + DisposableIsolationId::parse("operation-user-context-503").expect("valid isolation id"), + context, + )), + operation_calls: Rc::clone(&operation_calls), + observed_operations: Rc::clone(&observed_operations), + observed_sessions: Rc::clone(&observed_sessions), + observed_incarnations: Rc::clone(&observed_incarnations), + observed_epochs: Rc::clone(&observed_epochs), + fail_operation: Rc::clone(&fail_operation), + }; + let session_id = BrowserSessionId::new(503).expect("valid session id"); + let session = BrowserSession::start(session_id).expect("incarnation capacity"); + let incarnation = session.incarnation(); + let mut bound = session.bind_lifecycle_port(port); + + let authority = bound + .create_disposable_context() + .expect("accepted disposable context"); + assert_eq!( + bound.execute_authorized_context_operation(&authority, "set-viewport"), + Ok(context) + ); + assert_eq!(operation_calls.get(), 1); + assert_eq!(observed_operations.borrow().as_slice(), &["set-viewport"]); + assert_eq!(observed_sessions.borrow().as_slice(), &[session_id]); + assert_eq!(observed_incarnations.borrow().as_slice(), &[incarnation]); + assert_eq!(observed_epochs.borrow().as_slice(), &[1]); + + fail_operation.set(true); + assert_eq!( + bound.execute_authorized_context_operation(&authority, "remote-failure"), + Err(AuthorizedContextOperationError::Adapter(())) + ); + assert_eq!(operation_calls.get(), 2); + assert_eq!(observed_epochs.borrow().as_slice(), &[1, 1]); + fail_operation.set(false); + + let current = bound + .advance_context_epoch(context) + .expect("advance authority epoch"); + assert_eq!( + bound.execute_authorized_context_operation(&authority, "stale-operation"), + Err(AuthorizedContextOperationError::BrowserSession( + BrowserSessionError::AuthorityMismatch + )) + ); + assert_eq!( + operation_calls.get(), + 2, + "stale authority must fail before the bound adapter observes an operation" + ); + assert_eq!( + observed_epochs.borrow().as_slice(), + &[1, 1], + "stale authority must not emit an adapter request or provenance epoch" + ); + + assert_eq!( + bound.execute_authorized_context_operation(¤t, "reconcile-liveness"), + Ok(context) + ); + assert_eq!(operation_calls.get(), 3); + assert_eq!( + observed_operations.borrow().as_slice(), + &["set-viewport", "remote-failure", "reconcile-liveness"] + ); + assert_eq!( + observed_sessions.borrow().as_slice(), + &[session_id, session_id, session_id], + "the purpose-bounded adapter must observe only the bound Browser Session identity" + ); + assert_eq!( + observed_incarnations.borrow().as_slice(), + &[incarnation, incarnation, incarnation], + "the purpose-bounded adapter must observe only the bound Browser Session incarnation" + ); + assert_eq!( + observed_epochs.borrow().as_slice(), + &[1, 1, 2], + "adapter requests must retain the exact validated authority epoch" + ); +} diff --git a/crates/originweave-browser-session/tests/bound_session_abandonment.rs b/crates/originweave-browser-session/tests/bound_session_abandonment.rs new file mode 100644 index 000000000..3c57318d3 --- /dev/null +++ b/crates/originweave-browser-session/tests/bound_session_abandonment.rs @@ -0,0 +1,187 @@ +use std::cell::Cell; +use std::rc::Rc; +use std::sync::Mutex; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, DisposableContextCreateCompletion, + DisposableContextCreateCompletionError, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, + abandoned_bound_session_count, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +static ABANDONMENT_COUNTER_LOCK: Mutex<()> = Mutex::new(()); + +struct AbandonmentPort { + handle: Option, + destroy_calls: Rc>, +} + +impl DisposableContextPort for AbandonmentPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.handle + .take() + .ok_or(DisposableContextCreateError::CreateFailedClean) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_calls.set(self.destroy_calls.get() + 1); + Ok(()) + } +} + +fn port_for(context: u64, destroy_calls: &Rc>) -> AbandonmentPort { + AbandonmentPort { + handle: Some(DisposableContextHandle::new( + DisposableIsolationId::parse(&format!("abandoned-user-context-{context}")) + .expect("valid isolation id"), + BrowsingContextId::new(context).expect("valid browsing context"), + )), + destroy_calls: Rc::clone(destroy_calls), + } +} + +#[test] +fn dropping_unresolved_bound_session_is_observable_without_implicit_browser_io() { + let _guard = ABANDONMENT_COUNTER_LOCK + .lock() + .expect("abandonment counter test lock"); + let destroy_calls = Rc::new(Cell::new(0)); + let before = abandoned_bound_session_count(); + let session = BrowserSession::start(BrowserSessionId::new(504).expect("valid session id")) + .expect("incarnation capacity"); + let mut bound = session.bind_lifecycle_port(port_for(504, &destroy_calls)); + let _authority = bound + .create_disposable_context() + .expect("accepted disposable context"); + + drop(bound); + + assert_eq!( + destroy_calls.get(), + 0, + "Drop must never pretend synchronous browser cleanup succeeded" + ); + assert!( + abandoned_bound_session_count() > before, + "unresolved bound-session abandonment must be observable to recovery/operability code" + ); +} + +#[test] +fn transport_loss_without_remote_ownership_is_not_counted_as_abandonment() { + let _guard = ABANDONMENT_COUNTER_LOCK + .lock() + .expect("abandonment counter test lock"); + let destroy_calls = Rc::new(Cell::new(0)); + let before = abandoned_bound_session_count(); + + let empty_session = BrowserSession::start(BrowserSessionId::new(507).expect("valid session id")) + .expect("incarnation capacity"); + let mut empty_bound = empty_session.bind_lifecycle_port(port_for(507, &destroy_calls)); + assert!(empty_bound.record_transport_loss()); + drop(empty_bound); + assert_eq!( + abandoned_bound_session_count(), + before, + "transport loss with no owned or uncertain browser context is not unresolved remote ownership" + ); + + let session = BrowserSession::start(BrowserSessionId::new(508).expect("valid session id")) + .expect("incarnation capacity"); + let mut bound = session.bind_lifecycle_port(port_for(508, &destroy_calls)); + let authority = bound + .create_disposable_context() + .expect("accepted disposable context"); + bound + .destroy_disposable_context(&authority) + .expect("proven destruction"); + assert!(bound.record_transport_loss()); + drop(bound); + + assert_eq!(destroy_calls.get(), 1); + assert_eq!( + abandoned_bound_session_count(), + before, + "transport loss after all remote ownership was proven destroyed must not create a false abandonment signal" + ); +} + +#[test] +fn failed_finish_retains_same_bound_owner_for_cleanup_and_retry() { + let _guard = ABANDONMENT_COUNTER_LOCK + .lock() + .expect("abandonment counter test lock"); + let destroy_calls = Rc::new(Cell::new(0)); + let before = abandoned_bound_session_count(); + let session = BrowserSession::start(BrowserSessionId::new(506).expect("valid session id")) + .expect("incarnation capacity"); + let mut bound = session.bind_lifecycle_port(port_for(506, &destroy_calls)); + let authority = bound + .create_disposable_context() + .expect("accepted disposable context"); + + assert_eq!( + bound.finish(), + Err(BrowserSessionError::ActiveContextRemains) + ); + assert_eq!( + abandoned_bound_session_count(), + before, + "a failed deliberate finish must retain the bound lifecycle owner instead of dropping it as abandonment" + ); + assert_eq!( + destroy_calls.get(), + 0, + "failed finish validation must not perform implicit browser cleanup" + ); + + bound + .destroy_disposable_context(&authority) + .expect("the same bound lifecycle owner must remain available for cleanup"); + assert_eq!(destroy_calls.get(), 1); + bound + .finish() + .expect("retry succeeds after proven destruction"); + drop(bound); + assert_eq!( + abandoned_bound_session_count(), + before, + "successful retry must leave no abandonment signal" + ); +} + +#[test] +fn proven_destruction_can_finish_without_abandonment_path() { + let _guard = ABANDONMENT_COUNTER_LOCK + .lock() + .expect("abandonment counter test lock"); + let destroy_calls = Rc::new(Cell::new(0)); + let session = BrowserSession::start(BrowserSessionId::new(505).expect("valid session id")) + .expect("incarnation capacity"); + let mut bound = session.bind_lifecycle_port(port_for(505, &destroy_calls)); + let authority = bound + .create_disposable_context() + .expect("accepted disposable context"); + bound + .destroy_disposable_context(&authority) + .expect("proven destruction"); + bound + .finish() + .expect("end normally after proven destruction"); + assert_eq!(destroy_calls.get(), 1); +} diff --git a/crates/originweave-browser-session/tests/bound_session_debug_redaction.rs b/crates/originweave-browser-session/tests/bound_session_debug_redaction.rs new file mode 100644 index 000000000..edbd99a1c --- /dev/null +++ b/crates/originweave-browser-session/tests/bound_session_debug_redaction.rs @@ -0,0 +1,68 @@ +use std::cell::Cell; +use std::fmt; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, +}; +use originweave_core::BrowserSessionId; + +struct SideEffectingDebugPort { + debug_callbacks: Rc>, +} + +impl fmt::Debug for SideEffectingDebugPort { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.debug_callbacks + .set(self.debug_callbacks.get().saturating_add(1)); + formatter.write_str("adapter-secret-sentinel") + } +} + +impl DisposableContextPort for SideEffectingDebugPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + Err(DisposableContextCreateError::CreateFailedClean) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Ok(()) + } +} + +#[test] +fn bound_session_debug_never_executes_or_exposes_adapter_debug() { + let debug_callbacks = Rc::new(Cell::new(0)); + let port = SideEffectingDebugPort { + debug_callbacks: Rc::clone(&debug_callbacks), + }; + let session = BrowserSession::start(BrowserSessionId::new(502).expect("valid session id")) + .expect("incarnation capacity"); + let bound = session.bind_lifecycle_port(port); + + let rendered = format!("{bound:?}"); + + assert_eq!( + debug_callbacks.get(), + 0, + "formatting a bound session must not execute adapter-owned Debug code" + ); + assert!( + !rendered.contains("adapter-secret-sentinel"), + "bound-session diagnostics must not expose adapter-internal state" + ); +} diff --git a/crates/originweave-browser-session/tests/browser_session_view_debug_redaction.rs b/crates/originweave-browser-session/tests/browser_session_view_debug_redaction.rs new file mode 100644 index 000000000..7e36b6037 --- /dev/null +++ b/crates/originweave-browser-session/tests/browser_session_view_debug_redaction.rs @@ -0,0 +1,58 @@ +use originweave_browser_session::{ + BrowserSession, DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +struct DebugExposurePort; + +impl DisposableContextPort for DebugExposurePort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + Ok(DisposableContextHandle::new( + DisposableIsolationId::parse("browser-session-debug-secret-isolation") + .expect("lossless isolation id"), + BrowsingContextId::new(777).expect("valid browsing context"), + )) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Ok(()) + } +} + +#[test] +fn read_only_browser_session_view_debug_does_not_expose_remote_identity() { + let session = BrowserSession::start(BrowserSessionId::new(777).expect("valid session id")) + .expect("incarnation capacity"); + let mut bound = session.bind_lifecycle_port(DebugExposurePort); + bound + .create_disposable_context() + .expect("accepted disposable context"); + + let bound_debug = format!("{bound:?}"); + assert!( + !bound_debug.contains("browser-session-debug-secret-isolation"), + "the bound wrapper already promises redacted diagnostics" + ); + + let read_only_debug = format!("{:?}", bound.browser_session()); + assert!( + !read_only_debug.contains("browser-session-debug-secret-isolation"), + "the public read-only Browser Session view must not bypass BoundBrowserSession debug redaction and disclose exact remote lifecycle identity" + ); +} diff --git a/crates/originweave-browser-session/tests/create_recovery_same_handle_distinct_fact.rs b/crates/originweave-browser-session/tests/create_recovery_same_handle_distinct_fact.rs new file mode 100644 index 000000000..a3b59af30 --- /dev/null +++ b/crates/originweave-browser-session/tests/create_recovery_same_handle_distinct_fact.rs @@ -0,0 +1,153 @@ +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionRecoveryEvidence, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateDisposition, DisposableContextCreateError, + DisposableContextCreateRecoveryEvidence, DisposableContextCreateRequest, + DisposableContextDestroyError, DisposableContextDestroyRequest, DisposableContextHandle, + DisposableContextPort, DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; +use std::cell::RefCell; +use std::rc::Rc; + +#[derive(Debug, Default)] +struct CreateAttemptLedger { + create_attempts: Vec, + completion_attempts: Vec<(u64, DisposableContextCreateDisposition)>, +} + +#[derive(Debug)] +struct SameHandleRejectedCompletionPort { + handle: DisposableContextHandle, + ledger: Rc>, +} + +impl SameHandleRejectedCompletionPort { + fn new( + handle: DisposableContextHandle, + ledger: Rc>, + ) -> Self { + Self { handle, ledger } + } +} + +impl DisposableContextPort for SameHandleRejectedCompletionPort { + fn create_disposable_context( + &mut self, + request: &DisposableContextCreateRequest, + ) -> Result { + self.ledger + .borrow_mut() + .create_attempts + .push(request.attempt_epoch().value()); + Ok(self.handle.clone()) + } + + fn complete_disposable_context_creation( + &mut self, + completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + self.ledger.borrow_mut().completion_attempts.push(( + completion.attempt_epoch().value(), + completion.disposition(), + )); + if completion.disposition() == DisposableContextCreateDisposition::Rejected { + Err(DisposableContextCreateCompletionError::CompletionFailed) + } else { + Ok(()) + } + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Ok(()) + } +} + +#[test] +fn same_valued_rejected_create_keeps_prior_ownership_as_a_distinct_recovery_fact() { + let handle = DisposableContextHandle::new( + DisposableIsolationId::parse("same-valued-create-recovery") + .expect("valid browser-issued isolation identity"), + BrowsingContextId::new(94_001).expect("valid browsing-context identity"), + ); + let session = BrowserSession::start( + BrowserSessionId::new(94_001).expect("valid browser-session identity"), + ) + .expect("browser-session incarnation capacity"); + let ledger = Rc::new(RefCell::new(CreateAttemptLedger::default())); + let port = SameHandleRejectedCompletionPort::new(handle.clone(), Rc::clone(&ledger)); + let mut bound = session.bind_lifecycle_port(port); + + let first_authority = bound + .create_disposable_context() + .expect("attempt 1 becomes the accepted owned context"); + assert_eq!(first_authority.context_epoch().value(), 1); + + assert_eq!( + bound.create_disposable_context(), + Err(BrowserSessionError::ContextCreationUncertain), + "attempt 2 returns the same remote handle but its rejected completion is unproven" + ); + + let observed = ledger.borrow(); + assert_eq!(observed.create_attempts, vec![1, 2]); + assert_eq!( + observed.completion_attempts, + vec![ + (1, DisposableContextCreateDisposition::Accepted), + (2, DisposableContextCreateDisposition::Rejected), + ], + "the adapter must observe the same aggregate-issued attempt identity that recovery evidence retains" + ); + drop(observed); + + assert_eq!(bound.browser_session().recovery_evidence().len(), 3); + assert!(bound + .browser_session() + .recovery_evidence() + .contains(&BrowserSessionRecoveryEvidence::DuplicateAdapterHandle( + handle.clone() + ))); + assert!(bound + .browser_session() + .recovery_evidence() + .contains(&BrowserSessionRecoveryEvidence::UnsettledAdapterHandle( + handle.clone() + ))); + assert!(bound + .browser_session() + .recovery_evidence() + .contains(&BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle( + handle.clone() + )), + "attempt 1 ownership and attempt 2 candidate are distinct lifecycle facts even when their remote handle values are equal" + ); + + let create_evidence = bound.browser_session().create_attempt_recovery_evidence(); + assert_eq!(create_evidence.len(), 2); + match &create_evidence[0] { + DisposableContextCreateRecoveryEvidence::DuplicateCandidate { + attempt_epoch, + context, + } => { + assert_eq!(attempt_epoch.value(), 2); + assert_eq!(context, &handle); + } + other => panic!("unexpected duplicate recovery evidence: {other:?}"), + } + match &create_evidence[1] { + DisposableContextCreateRecoveryEvidence::CompletionUnsettled { + attempt_epoch, + disposition, + context, + } => { + assert_eq!(attempt_epoch.value(), 2); + assert_eq!(*disposition, DisposableContextCreateDisposition::Rejected); + assert_eq!(context, &handle); + } + other => panic!("unexpected completion recovery evidence: {other:?}"), + } +} diff --git a/crates/originweave-browser-session/tests/creation_transaction_completion.rs b/crates/originweave-browser-session/tests/creation_transaction_completion.rs new file mode 100644 index 000000000..e0cb9c49c --- /dev/null +++ b/crates/originweave-browser-session/tests/creation_transaction_completion.rs @@ -0,0 +1,120 @@ +use std::cell::RefCell; +use std::collections::{BTreeMap, VecDeque}; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionIncarnation, BrowserSessionState, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateDisposition, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug, Default)] +struct CreationLedger { + session: Option, + incarnation: Option, + pending: BTreeMap, + accepted: Vec, + rejected: Vec, +} + +#[derive(Debug)] +struct TransactionalPort { + handles: VecDeque, + ledger: Rc>, +} + +impl TransactionalPort { + fn new(handles: Vec, ledger: Rc>) -> Self { + Self { + handles: handles.into(), + ledger, + } + } +} + +impl DisposableContextPort for TransactionalPort { + fn create_disposable_context( + &mut self, + request: &DisposableContextCreateRequest, + ) -> Result { + let handle = self + .handles + .pop_front() + .expect("fixture supplies one handle per create"); + let mut ledger = self.ledger.borrow_mut(); + ledger.session.get_or_insert(request.browser_session()); + ledger.incarnation.get_or_insert(request.incarnation()); + let prior = ledger + .pending + .insert(request.attempt_epoch().value(), handle.clone()); + assert!(prior.is_none(), "create attempts must not collide"); + Ok(handle) + } + + fn complete_disposable_context_creation( + &mut self, + completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + let mut ledger = self.ledger.borrow_mut(); + if ledger.session != Some(completion.browser_session()) + || ledger.incarnation != Some(completion.incarnation()) + { + return Err(DisposableContextCreateCompletionError::CompletionFailed); + } + let attempt = completion.attempt_epoch().value(); + if ledger.pending.remove(&attempt).is_none() { + return Err(DisposableContextCreateCompletionError::CompletionFailed); + } + match completion.disposition() { + DisposableContextCreateDisposition::Accepted => ledger.accepted.push(attempt), + DisposableContextCreateDisposition::Rejected => ledger.rejected.push(attempt), + } + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Ok(()) + } +} + +#[test] +fn accepted_and_rejected_create_candidates_are_correlated_by_exact_attempt() { + let context = BrowsingContextId::new(8010).expect("valid browsing context"); + let first = DisposableContextHandle::new( + DisposableIsolationId::parse("transaction-user-context-a").expect("valid isolation"), + context, + ); + let duplicate_context = DisposableContextHandle::new( + DisposableIsolationId::parse("transaction-user-context-b").expect("valid isolation"), + context, + ); + let ledger = Rc::new(RefCell::new(CreationLedger::default())); + let port = TransactionalPort::new(vec![first, duplicate_context], Rc::clone(&ledger)); + let session = BrowserSession::start(BrowserSessionId::new(801).expect("valid session id")) + .expect("incarnation capacity"); + let mut bound = session.bind_lifecycle_port(port); + + let accepted = bound + .create_disposable_context() + .expect("first candidate accepted"); + assert_eq!(accepted.context_epoch().value(), 1); + assert_eq!( + bound.create_disposable_context(), + Err(BrowserSessionError::DuplicateBrowsingContext) + ); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + + let ledger = ledger.borrow(); + assert!(ledger.pending.is_empty()); + assert_eq!(ledger.accepted, vec![1]); + assert_eq!(ledger.rejected, vec![2]); +} diff --git a/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs b/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs index 669f0723c..5b645dc8d 100644 --- a/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs +++ b/crates/originweave-browser-session/tests/destroy_failure_requires_recovery.rs @@ -1,27 +1,40 @@ +use std::cell::Cell; +use std::rc::Rc; + use originweave_browser_session::{ - BrowserSession, BrowserSessionError, BrowserSessionIncarnation, BrowserSessionRecoveryEvidence, - BrowserSessionState, DisposableContextCreateError, DisposableContextDestroyError, - DisposableContextHandle, DisposableContextPort, DisposableIsolationId, + BrowserSession, BrowserSessionError, BrowserSessionRecoveryEvidence, BrowserSessionState, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; #[derive(Debug)] struct FailingDestroyPort { next_handle: DisposableContextHandle, - create_calls: usize, - destroy_calls: usize, + create_calls: Rc>, + destroy_calls: Rc>, + observed_destroy_epoch: Rc>>, } impl FailingDestroyPort { - fn new(context: u64, isolation: &str) -> Result { + fn new( + context: u64, + isolation: &str, + create_calls: Rc>, + destroy_calls: Rc>, + observed_destroy_epoch: Rc>>, + ) -> Result { let isolation = DisposableIsolationId::parse(isolation) .map_err(|_| "static fixture isolation id must be valid")?; let browsing_context = BrowsingContextId::new(context) .map_err(|_| "static fixture browsing context id must be valid")?; Ok(Self { next_handle: DisposableContextHandle::new(isolation, browsing_context), - create_calls: 0, - destroy_calls: 0, + create_calls, + destroy_calls, + observed_destroy_epoch, }) } } @@ -29,20 +42,26 @@ impl FailingDestroyPort { impl DisposableContextPort for FailingDestroyPort { fn create_disposable_context( &mut self, - _browser_session: BrowserSessionId, - _incarnation: BrowserSessionIncarnation, + _request: &DisposableContextCreateRequest, ) -> Result { - self.create_calls += 1; + self.create_calls.set(self.create_calls.get() + 1); Ok(self.next_handle.clone()) } + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + fn destroy_disposable_context( &mut self, - _browser_session: BrowserSessionId, - _incarnation: BrowserSessionIncarnation, - _context: &DisposableContextHandle, + request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { - self.destroy_calls += 1; + self.destroy_calls.set(self.destroy_calls.get() + 1); + self.observed_destroy_epoch + .set(Some(request.context_epoch().value())); Err(DisposableContextDestroyError::DestroyFailed) } } @@ -57,46 +76,63 @@ fn destroy_failure_requires_recovery_before_any_new_authority() -> Result<(), &' let expected_isolation = DisposableIsolationId::parse("user-context-501") .map_err(|_| "static fixture recovery isolation id must be valid")?; let expected_handle = DisposableContextHandle::new(expected_isolation, context_id); - let mut session = BrowserSession::start(session_id) + let session = BrowserSession::start(session_id) .map_err(|_| "browser session incarnation must be available")?; - let mut failing_port = FailingDestroyPort::new(5010, "user-context-501")?; + let create_calls = Rc::new(Cell::new(0)); + let destroy_calls = Rc::new(Cell::new(0)); + let observed_destroy_epoch = Rc::new(Cell::new(None)); + let failing_port = FailingDestroyPort::new( + 5010, + "user-context-501", + Rc::clone(&create_calls), + Rc::clone(&destroy_calls), + Rc::clone(&observed_destroy_epoch), + )?; + let mut bound = session.bind_lifecycle_port(failing_port); - let authority = session - .create_disposable_context(&mut failing_port) + let authority = bound + .create_disposable_context() .map_err(|_| "fixture disposable context creation must succeed")?; + let expected_epoch = authority.context_epoch(); assert_eq!( - session.destroy_disposable_context(&authority, &mut failing_port), + bound.destroy_disposable_context(&authority), Err(BrowserSessionError::ContextDestructionFailed) ); - assert_eq!(failing_port.destroy_calls, 1); - assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); + assert_eq!(destroy_calls.get(), 1); + assert_eq!(observed_destroy_epoch.get(), Some(expected_epoch.value())); assert_eq!( - session.recovery_evidence(), - &[BrowserSessionRecoveryEvidence::UnprovenDestruction( - expected_handle - )] + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired ); - assert!(!session.transport_is_lost()); - - assert!(session.record_transport_loss()); - assert!(session.transport_is_lost()); - assert_eq!(session.state(), BrowserSessionState::RecoveryRequired); - assert!(!session.record_transport_loss()); + assert_eq!( + bound.browser_session().recovery_evidence(), + &[BrowserSessionRecoveryEvidence::UnprovenDestruction { + context: expected_handle, + context_epoch: expected_epoch, + }] + ); + assert!(!bound.browser_session().transport_is_lost()); - let mut later_port = FailingDestroyPort::new(5011, "user-context-501-later")?; + assert!(bound.record_transport_loss()); + assert!(bound.browser_session().transport_is_lost()); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + assert!(!bound.record_transport_loss()); assert_eq!( - session.create_disposable_context(&mut later_port), + bound.create_disposable_context(), Err(BrowserSessionError::SessionNotActive) ); - assert_eq!(later_port.create_calls, 0); + assert_eq!(create_calls.get(), 1); assert_eq!( - session.presentation_authority(context_id), + bound.presentation_authority(context_id), Err(BrowserSessionError::SessionNotActive) ); assert_eq!( - session.advance_context_epoch(context_id), + bound.advance_context_epoch(context_id), Err(BrowserSessionError::SessionNotActive) ); - assert_eq!(session.end(), Err(BrowserSessionError::SessionNotActive)); + assert_eq!(bound.end(), Err(BrowserSessionError::SessionNotActive)); Ok(()) } diff --git a/crates/originweave-browser-session/tests/lifecycle_port_authority.rs b/crates/originweave-browser-session/tests/lifecycle_port_authority.rs new file mode 100644 index 000000000..ea5432fcc --- /dev/null +++ b/crates/originweave-browser-session/tests/lifecycle_port_authority.rs @@ -0,0 +1,89 @@ +use std::cell::Cell; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug)] +struct RecordingPort { + create_calls: Rc>, + destroy_calls: Rc>, +} + +impl RecordingPort { + fn new(create_calls: Rc>, destroy_calls: Rc>) -> Self { + Self { + create_calls, + destroy_calls, + } + } +} + +impl DisposableContextPort for RecordingPort { + fn create_disposable_context( + &mut self, + request: &DisposableContextCreateRequest, + ) -> Result { + assert_eq!(request.browser_session(), BrowserSessionId::new(7).unwrap()); + self.create_calls.set(self.create_calls.get() + 1); + Ok(DisposableContextHandle::new( + DisposableIsolationId::parse("aggregate-issued-request").expect("valid isolation id"), + BrowsingContextId::new(41).expect("valid browsing context"), + )) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + assert_eq!(request.browser_session(), BrowserSessionId::new(7).unwrap()); + assert_eq!( + request.context().browsing_context(), + BrowsingContextId::new(41).unwrap() + ); + self.destroy_calls.set(self.destroy_calls.get() + 1); + Ok(()) + } +} + +#[test] +fn aggregate_issued_request_is_reachable_only_through_owned_port_binding() { + let session = BrowserSession::start(BrowserSessionId::new(7).expect("valid session id")) + .expect("incarnation capacity"); + let approved_create_calls = Rc::new(Cell::new(0)); + let approved_destroy_calls = Rc::new(Cell::new(0)); + let other_create_calls = Rc::new(Cell::new(0)); + let other_destroy_calls = Rc::new(Cell::new(0)); + let _unbound_other_port = RecordingPort::new( + Rc::clone(&other_create_calls), + Rc::clone(&other_destroy_calls), + ); + let mut bound = session.bind_lifecycle_port(RecordingPort::new( + Rc::clone(&approved_create_calls), + Rc::clone(&approved_destroy_calls), + )); + + let authority = bound + .create_disposable_context() + .expect("Browser Session-issued create request"); + assert_eq!(approved_create_calls.get(), 1); + assert_eq!(other_create_calls.get(), 0); + + bound + .destroy_disposable_context(&authority) + .expect("Browser Session-issued destroy request"); + assert_eq!(approved_destroy_calls.get(), 1); + assert_eq!(other_destroy_calls.get(), 0); +} diff --git a/crates/originweave-browser-session/tests/lifecycle_port_preflight_side_effect.rs b/crates/originweave-browser-session/tests/lifecycle_port_preflight_side_effect.rs new file mode 100644 index 000000000..cee8c46ed --- /dev/null +++ b/crates/originweave-browser-session/tests/lifecycle_port_preflight_side_effect.rs @@ -0,0 +1,85 @@ +use std::cell::Cell; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug)] +struct SideEffectingIdentityPort { + identity_callbacks: Rc>, + create_calls: Rc>, +} + +impl SideEffectingIdentityPort { + fn new(identity_callbacks: Rc>, create_calls: Rc>) -> Self { + Self { + identity_callbacks, + create_calls, + } + } + + fn identity_probe(&self) { + self.identity_callbacks + .set(self.identity_callbacks.get().saturating_add(1)); + } +} + +impl DisposableContextPort for SideEffectingIdentityPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.create_calls + .set(self.create_calls.get().saturating_add(1)); + Ok(DisposableContextHandle::new( + DisposableIsolationId::parse("preflight-user-context").expect("valid isolation id"), + BrowsingContextId::new(401).expect("valid browsing context"), + )) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Ok(()) + } +} + +#[test] +fn lifecycle_binding_invokes_no_adapter_callback_before_authorized_create() { + let session = BrowserSession::start(BrowserSessionId::new(401).expect("valid session id")) + .expect("incarnation capacity"); + let identity_callbacks = Rc::new(Cell::new(0)); + let create_calls = Rc::new(Cell::new(0)); + let port = + SideEffectingIdentityPort::new(Rc::clone(&identity_callbacks), Rc::clone(&create_calls)); + + // Prove the fixture observes a shared-reference callback without retaining adapter access after bind. + port.identity_probe(); + assert_eq!(identity_callbacks.get(), 1); + identity_callbacks.set(0); + + let mut bound = session.bind_lifecycle_port(port); + assert_eq!( + identity_callbacks.get(), + 0, + "binding invoked adapter code before aggregate-issued lifecycle authority existed" + ); + bound + .create_disposable_context() + .expect("authorized create"); + assert_eq!(identity_callbacks.get(), 0); + assert_eq!(create_calls.get(), 1); +} diff --git a/crates/originweave-browser-session/tests/lifecycle_port_same_id_spoof.rs b/crates/originweave-browser-session/tests/lifecycle_port_same_id_spoof.rs new file mode 100644 index 000000000..bc692f31d --- /dev/null +++ b/crates/originweave-browser-session/tests/lifecycle_port_same_id_spoof.rs @@ -0,0 +1,123 @@ +use std::cell::Cell; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug)] +struct RecordingPort { + context: BrowsingContextId, + isolation: &'static str, + create_calls: Rc>, + destroy_calls: Rc>, +} + +impl RecordingPort { + fn new( + context: u64, + isolation: &'static str, + create_calls: Rc>, + destroy_calls: Rc>, + ) -> Self { + Self { + context: BrowsingContextId::new(context).expect("valid browsing context"), + isolation, + create_calls, + destroy_calls, + } + } +} + +impl DisposableContextPort for RecordingPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.create_calls.set(self.create_calls.get() + 1); + Ok(DisposableContextHandle::new( + DisposableIsolationId::parse(self.isolation).expect("valid isolation id"), + self.context, + )) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_calls.set(self.destroy_calls.get() + 1); + Ok(()) + } +} + +#[test] +fn distinct_adapter_cannot_be_substituted_for_create_after_binding() { + let session = BrowserSession::start(BrowserSessionId::new(17).expect("valid session id")) + .expect("incarnation capacity"); + let approved_create_calls = Rc::new(Cell::new(0)); + let approved_destroy_calls = Rc::new(Cell::new(0)); + let spoof_create_calls = Rc::new(Cell::new(0)); + let spoof_destroy_calls = Rc::new(Cell::new(0)); + let approved_port = RecordingPort::new( + 41, + "approved-isolation", + Rc::clone(&approved_create_calls), + Rc::clone(&approved_destroy_calls), + ); + let _spoofing_port = RecordingPort::new( + 42, + "spoofed-isolation", + Rc::clone(&spoof_create_calls), + Rc::clone(&spoof_destroy_calls), + ); + let mut bound = session.bind_lifecycle_port(approved_port); + + bound + .create_disposable_context() + .expect("bound adapter creates context"); + assert_eq!(approved_create_calls.get(), 1); + assert_eq!(spoof_create_calls.get(), 0); +} + +#[test] +fn distinct_adapter_cannot_be_substituted_for_destroy_after_binding() { + let session = BrowserSession::start(BrowserSessionId::new(18).expect("valid session id")) + .expect("incarnation capacity"); + let approved_create_calls = Rc::new(Cell::new(0)); + let approved_destroy_calls = Rc::new(Cell::new(0)); + let spoof_create_calls = Rc::new(Cell::new(0)); + let spoof_destroy_calls = Rc::new(Cell::new(0)); + let approved_port = RecordingPort::new( + 51, + "approved-isolation", + Rc::clone(&approved_create_calls), + Rc::clone(&approved_destroy_calls), + ); + let _spoofing_port = RecordingPort::new( + 52, + "spoofed-isolation", + Rc::clone(&spoof_create_calls), + Rc::clone(&spoof_destroy_calls), + ); + let mut bound = session.bind_lifecycle_port(approved_port); + let authority = bound + .create_disposable_context() + .expect("bound adapter creates context"); + + bound + .destroy_disposable_context(&authority) + .expect("bound adapter destroys context"); + assert_eq!(approved_destroy_calls.get(), 1); + assert_eq!(spoof_destroy_calls.get(), 0); +} diff --git a/crates/originweave-browser-session/tests/proven_destroy_releases_hot_ownership.rs b/crates/originweave-browser-session/tests/proven_destroy_releases_hot_ownership.rs new file mode 100644 index 000000000..dd3dddc21 --- /dev/null +++ b/crates/originweave-browser-session/tests/proven_destroy_releases_hot_ownership.rs @@ -0,0 +1,118 @@ +use std::cell::Cell; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, DisposableContextCreateCompletion, + DisposableContextCreateCompletionError, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +struct ReusedHandlePort { + handle: DisposableContextHandle, + create_calls: Rc>, + destroy_calls: Rc>, +} + +impl DisposableContextPort for ReusedHandlePort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.create_calls.set(self.create_calls.get() + 1); + Ok(self.handle.clone()) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_calls.set(self.destroy_calls.get() + 1); + Ok(()) + } +} + +#[test] +fn proven_destroy_releases_hot_ownership_without_resurrecting_stale_authority( +) -> Result<(), &'static str> { + let browsing_context = BrowsingContextId::new(8_100) + .map_err(|_| "static browsing context id must be valid")?; + let isolation = DisposableIsolationId::parse("bounded-hot-ownership") + .map_err(|_| "static isolation id must be valid")?; + let handle = DisposableContextHandle::new(isolation, browsing_context); + let create_calls = Rc::new(Cell::new(0)); + let destroy_calls = Rc::new(Cell::new(0)); + let port = ReusedHandlePort { + handle, + create_calls: Rc::clone(&create_calls), + destroy_calls: Rc::clone(&destroy_calls), + }; + let session = BrowserSession::start( + BrowserSessionId::new(810).map_err(|_| "static browser session id must be valid")?, + ) + .map_err(|_| "browser session incarnation must be available")?; + let mut bound = session.bind_lifecycle_port(port); + + let first = bound + .create_disposable_context() + .map_err(|_| "first ownership generation must be accepted")?; + assert_eq!(first.context_epoch().value(), 1); + bound + .destroy_disposable_context(&first) + .map_err(|_| "first ownership generation must be proven destroyed")?; + assert_eq!(create_calls.get(), 1); + assert_eq!(destroy_calls.get(), 1); + assert_eq!( + bound.destroy_disposable_context(&first), + Err(BrowserSessionError::ContextNotOwned), + "a proven-destroyed authority must fail before another destroy call" + ); + assert_eq!(destroy_calls.get(), 1); + + let second = bound + .create_disposable_context() + .map_err(|_| "proven destruction must release the reusable remote identity from hot ownership")?; + assert_eq!(second.browsing_context(), first.browsing_context()); + assert_eq!(second.isolation(), first.isolation()); + assert_eq!(second.context_epoch().value(), first.context_epoch().value() + 1); + assert_eq!( + bound.destroy_disposable_context(&first), + Err(BrowserSessionError::AuthorityMismatch), + "same-valued remote identity reuse must not resurrect the predecessor epoch" + ); + assert_eq!( + destroy_calls.get(), + 1, + "stale authority must fail before lifecycle adapter I/O" + ); + bound + .destroy_disposable_context(&second) + .map_err(|_| "current ownership generation must still authorize exact destruction")?; + + let mut previous_epoch = second.context_epoch().value(); + for _ in 0..256 { + let current = bound + .create_disposable_context() + .map_err(|_| "proven-destroyed identity reuse must remain bounded and admissible")?; + assert_eq!(current.context_epoch().value(), previous_epoch + 1); + previous_epoch = current.context_epoch().value(); + bound + .destroy_disposable_context(¤t) + .map_err(|_| "each current ownership generation must be proven destroyed")?; + } + + assert_eq!(create_calls.get(), 258); + assert_eq!(destroy_calls.get(), 258); + bound + .end() + .map_err(|_| "no live or uncertain ownership may remain after proven destruction")?; + Ok(()) +} diff --git a/crates/originweave-browser-session/tests/recovery_exact_fact_settlement.rs b/crates/originweave-browser-session/tests/recovery_exact_fact_settlement.rs new file mode 100644 index 000000000..62b15327f --- /dev/null +++ b/crates/originweave-browser-session/tests/recovery_exact_fact_settlement.rs @@ -0,0 +1,381 @@ +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionRecoveryEvidence, BrowserSessionState, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRecoveryEvidence, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, RecoverySettlementError, + RecoverySettlementPort, RecoverySettlementRequest, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ObservedAbsentProof { + browsing_context: BrowsingContextId, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SettlementVerificationError { + WrongFact, +} + +struct SettlementPort { + create_results: VecDeque>, + settlement_calls: Rc>, + settled_recovery: Rc>>, + settled_create_attempts: Rc>>, +} + +impl DisposableContextPort for SettlementPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.create_results + .pop_front() + .unwrap_or(Err(DisposableContextCreateError::CreateFailedClean)) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Err(DisposableContextDestroyError::DestroyFailed) + } +} + +impl RecoverySettlementPort for SettlementPort { + type Proof = ObservedAbsentProof; + type Error = SettlementVerificationError; + + fn verify_recovery_settlement( + &mut self, + request: &RecoverySettlementRequest, + ) -> Result<(), Self::Error> { + self.settlement_calls.set(self.settlement_calls.get() + 1); + if let Some(evidence) = request.recovery_evidence() { + let expected_context = match evidence { + BrowserSessionRecoveryEvidence::UnprovenDestruction { context, .. } + | BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(context) + | BrowserSessionRecoveryEvidence::TransportLossOwnedHandle(context) + | BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(context) + | BrowserSessionRecoveryEvidence::UnsettledAdapterHandle(context) => { + Some(context.browsing_context()) + } + BrowserSessionRecoveryEvidence::PartialCreationIsolation(_) => None, + }; + if expected_context.is_some_and(|context| context != request.proof().browsing_context) { + return Err(SettlementVerificationError::WrongFact); + } + self.settled_recovery.borrow_mut().push(evidence.clone()); + return Ok(()); + } + if let Some(evidence) = request.create_attempt_recovery_evidence() { + let expected_context = match evidence { + DisposableContextCreateRecoveryEvidence::DuplicateCandidate { context, .. } + | DisposableContextCreateRecoveryEvidence::CompletionUnsettled { context, .. } => { + Some(context.browsing_context()) + } + DisposableContextCreateRecoveryEvidence::FailedUncertain { .. } => None, + }; + if expected_context.is_some_and(|context| context != request.proof().browsing_context) { + return Err(SettlementVerificationError::WrongFact); + } + self.settled_create_attempts.borrow_mut().push(evidence.clone()); + return Ok(()); + } + Err(SettlementVerificationError::WrongFact) + } +} + +fn isolation(value: &str) -> Result { + DisposableIsolationId::parse(value).map_err(|_| "fixture isolation must be representable") +} + +fn context(value: u64) -> Result { + BrowsingContextId::new(value).map_err(|_| "fixture browsing context must be valid") +} + +fn session(value: u64) -> Result { + BrowserSessionId::new(value).map_err(|_| "fixture session must be valid") +} + +fn handle(isolation_id: &str, context_id: u64) -> Result { + Ok(DisposableContextHandle::new( + isolation(isolation_id)?, + context(context_id)?, + )) +} + +#[test] +fn exact_fact_settlement_is_single_use_local_and_does_not_erase_sibling_uncertainty( +) -> Result<(), &'static str> { + let first_handle = handle("recovery-settlement-a", 81_001)?; + let second_handle = handle("recovery-settlement-b", 81_002)?; + let settlement_calls = Rc::new(Cell::new(0)); + let settled_recovery = Rc::new(RefCell::new(Vec::new())); + let settled_create_attempts = Rc::new(RefCell::new(Vec::new())); + let port = SettlementPort { + create_results: VecDeque::from([ + Ok(first_handle.clone()), + Ok(second_handle.clone()), + ]), + settlement_calls: Rc::clone(&settlement_calls), + settled_recovery: Rc::clone(&settled_recovery), + settled_create_attempts: Rc::clone(&settled_create_attempts), + }; + let mut bound = BrowserSession::start(session(8_101)?) + .map_err(|_| "browser session incarnation must be available")? + .bind_lifecycle_port(port); + let first_authority = bound + .create_disposable_context() + .map_err(|_| "first create must succeed")?; + let second_authority = bound + .create_disposable_context() + .map_err(|_| "second create must succeed")?; + assert_eq!( + bound.destroy_disposable_context(&first_authority), + Err(BrowserSessionError::ContextDestructionFailed) + ); + assert_eq!(bound.browser_session().state(), BrowserSessionState::RecoveryRequired); + assert_eq!(bound.browser_session().recovery_evidence().len(), 2); + + let mut recovery = bound + .into_recovery() + .map_err(|_| "RecoveryRequired must enter recovery custody")?; + let first_fact = recovery + .recovery_fact(0) + .ok_or("first recovery fact must be addressable")?; + let stale_replay = recovery + .recovery_fact(0) + .ok_or("same current fact may be inspected twice before settlement")?; + let sibling_fact = recovery + .recovery_fact(1) + .ok_or("sibling recovery fact must be addressable")?; + + recovery + .settle_recovery_fact( + first_fact, + ObservedAbsentProof { + browsing_context: first_handle.browsing_context(), + }, + ) + .map_err(|_| "independently verified exact first fact must settle")?; + assert_eq!(settlement_calls.get(), 1); + assert_eq!(recovery.state(), BrowserSessionState::RecoveryRequired); + assert_eq!(recovery.recovery_evidence().len(), 1); + assert!(recovery.recovery_evidence().iter().any(|evidence| { + matches!( + evidence, + BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(context) + if context == &second_handle + ) + })); + + assert_eq!( + recovery.settle_recovery_fact( + stale_replay, + ObservedAbsentProof { + browsing_context: first_handle.browsing_context(), + }, + ), + Err(RecoverySettlementError::StaleFact) + ); + assert_eq!( + settlement_calls.get(), + 1, + "stale replay must fail before adapter proof verification" + ); + + assert_eq!( + recovery.settle_recovery_fact( + sibling_fact, + ObservedAbsentProof { + browsing_context: first_handle.browsing_context(), + }, + ), + Err(RecoverySettlementError::StaleFact), + "settling one fact invalidates previously issued sibling handles; callers must reread current custody" + ); + assert_eq!(settlement_calls.get(), 1); + + let current_sibling = recovery + .recovery_fact(0) + .ok_or("remaining sibling fact must be re-addressable after revision change")?; + assert_eq!( + recovery.settle_recovery_fact( + current_sibling, + ObservedAbsentProof { + browsing_context: first_handle.browsing_context(), + }, + ), + Err(RecoverySettlementError::Adapter( + SettlementVerificationError::WrongFact + )) + ); + assert_eq!(settlement_calls.get(), 2); + assert_eq!(recovery.state(), BrowserSessionState::RecoveryRequired); + assert_eq!(recovery.recovery_evidence().len(), 1); + + let current_sibling = recovery + .recovery_fact(0) + .ok_or("failed proof must leave the exact sibling fact current")?; + recovery + .settle_recovery_fact( + current_sibling, + ObservedAbsentProof { + browsing_context: second_handle.browsing_context(), + }, + ) + .map_err(|_| "qualified sibling absence must settle")?; + assert_eq!(settlement_calls.get(), 3); + assert!(recovery.recovery_evidence().is_empty()); + assert!(recovery.create_attempt_recovery_evidence().is_empty()); + assert_eq!( + recovery.state(), + BrowserSessionState::Ended, + "settlement may reach a terminal closed state but must never restore ordinary browser authority" + ); + assert_eq!(settled_recovery.borrow().len(), 2); + assert!(settled_create_attempts.borrow().is_empty()); + let _ = second_authority; + Ok(()) +} + +#[test] +fn foreign_fact_is_rejected_before_the_other_session_adapter_observes_proof( +) -> Result<(), &'static str> { + fn recovering_session( + session_id: u64, + context_id: u64, + isolation_id: &str, + settlement_calls: Rc>, + ) -> Result, &'static str> + { + let owned = handle(isolation_id, context_id)?; + let port = SettlementPort { + create_results: VecDeque::from([Ok(owned)]), + settlement_calls, + settled_recovery: Rc::new(RefCell::new(Vec::new())), + settled_create_attempts: Rc::new(RefCell::new(Vec::new())), + }; + let mut bound = BrowserSession::start(session(session_id)?) + .map_err(|_| "browser session incarnation must be available")? + .bind_lifecycle_port(port); + let authority = bound + .create_disposable_context() + .map_err(|_| "fixture create must succeed")?; + assert_eq!( + bound.destroy_disposable_context(&authority), + Err(BrowserSessionError::ContextDestructionFailed) + ); + bound + .into_recovery() + .map_err(|_| "fixture must enter recovery custody") + } + + let first_calls = Rc::new(Cell::new(0)); + let second_calls = Rc::new(Cell::new(0)); + let first = recovering_session(8_201, 82_001, "foreign-fact-a", first_calls)?; + let mut second = recovering_session( + 8_202, + 82_002, + "foreign-fact-b", + Rc::clone(&second_calls), + )?; + let foreign_fact = first + .recovery_fact(0) + .ok_or("foreign recovery fact must exist")?; + + assert_eq!( + second.settle_recovery_fact( + foreign_fact, + ObservedAbsentProof { + browsing_context: context(82_001)?, + }, + ), + Err(RecoverySettlementError::AuthorityMismatch) + ); + assert_eq!( + second_calls.get(), + 0, + "foreign session/incarnation fact must fail before adapter proof verification" + ); + assert_eq!(second.recovery_evidence().len(), 1); + Ok(()) +} + +#[test] +fn dual_recovery_ledgers_require_independent_exact_fact_retirement( +) -> Result<(), &'static str> { + let uncertain_handle = handle("uncertain-create-fact", 83_001)?; + let settlement_calls = Rc::new(Cell::new(0)); + let settled_recovery = Rc::new(RefCell::new(Vec::new())); + let settled_create_attempts = Rc::new(RefCell::new(Vec::new())); + let port = SettlementPort { + create_results: VecDeque::from([Err( + DisposableContextCreateError::CreateFailedUncertain(Some( + uncertain_handle.isolation().clone(), + )), + )]), + settlement_calls: Rc::clone(&settlement_calls), + settled_recovery: Rc::clone(&settled_recovery), + settled_create_attempts: Rc::clone(&settled_create_attempts), + }; + let mut bound = BrowserSession::start(session(8_301)?) + .map_err(|_| "browser session incarnation must be available")? + .bind_lifecycle_port(port); + assert_eq!( + bound.create_disposable_context(), + Err(BrowserSessionError::ContextCreationUncertain) + ); + let mut recovery = bound + .into_recovery() + .map_err(|_| "uncertain create must enter recovery custody")?; + assert_eq!(recovery.recovery_evidence().len(), 1); + assert_eq!(recovery.create_attempt_recovery_evidence().len(), 1); + + let identity_fact = recovery + .recovery_fact(0) + .ok_or("identity recovery fact must exist")?; + recovery + .settle_recovery_fact( + identity_fact, + ObservedAbsentProof { + browsing_context: uncertain_handle.browsing_context(), + }, + ) + .map_err(|_| "independently qualified identity fact must settle")?; + assert!(recovery.recovery_evidence().is_empty()); + assert_eq!(recovery.create_attempt_recovery_evidence().len(), 1); + assert_eq!(recovery.state(), BrowserSessionState::RecoveryRequired); + + let transaction_fact = recovery + .create_attempt_recovery_fact(0) + .ok_or("create-attempt fact must remain separately addressable")?; + recovery + .settle_recovery_fact( + transaction_fact, + ObservedAbsentProof { + browsing_context: uncertain_handle.browsing_context(), + }, + ) + .map_err(|_| "independently qualified create-attempt fact must settle")?; + assert!(recovery.recovery_evidence().is_empty()); + assert!(recovery.create_attempt_recovery_evidence().is_empty()); + assert_eq!(recovery.state(), BrowserSessionState::Ended); + assert_eq!(settlement_calls.get(), 2); + assert_eq!(settled_recovery.borrow().len(), 1); + assert_eq!(settled_create_attempts.borrow().len(), 1); + Ok(()) +} diff --git a/crates/originweave-browser-session/tests/recovery_handoff_requires_unresolved_ownership.rs b/crates/originweave-browser-session/tests/recovery_handoff_requires_unresolved_ownership.rs new file mode 100644 index 000000000..55ed78170 --- /dev/null +++ b/crates/originweave-browser-session/tests/recovery_handoff_requires_unresolved_ownership.rs @@ -0,0 +1,96 @@ +use originweave_browser_session::{ + BrowserSession, BrowserSessionState, DisposableContextCreateCompletion, + DisposableContextCreateCompletionError, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +struct CleanPort { + handle: DisposableContextHandle, +} + +impl DisposableContextPort for CleanPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + Ok(self.handle.clone()) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Ok(()) + } +} + +fn clean_port(context: u64, isolation: &str) -> Result { + let isolation = DisposableIsolationId::parse(isolation) + .map_err(|_| "static fixture isolation id must be valid")?; + let browsing_context = BrowsingContextId::new(context) + .map_err(|_| "static fixture browsing context id must be valid")?; + Ok(CleanPort { + handle: DisposableContextHandle::new(isolation, browsing_context), + }) +} + +#[test] +fn transport_loss_without_remote_ownership_cannot_enter_recovery_custody( +) -> Result<(), &'static str> { + let session = BrowserSession::start( + BrowserSessionId::new(7_120).map_err(|_| "static session id must be valid")?, + ) + .map_err(|_| "browser session incarnation must be available")?; + let mut bound = session.bind_lifecycle_port(clean_port(71_200, "unused-recovery-port")?); + + assert!(bound.record_transport_loss()); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::TransportLost + ); + assert!(bound.browser_session().recovery_evidence().is_empty()); + assert!( + bound.into_recovery().is_err(), + "transport loss without unresolved remote ownership must not mint recovery adapter authority" + ); + Ok(()) +} + +#[test] +fn transport_loss_after_proven_destruction_cannot_reopen_recovery_custody( +) -> Result<(), &'static str> { + let session = BrowserSession::start( + BrowserSessionId::new(7_121).map_err(|_| "static session id must be valid")?, + ) + .map_err(|_| "browser session incarnation must be available")?; + let mut bound = session.bind_lifecycle_port(clean_port(71_210, "destroyed-recovery-port")?); + + let authority = bound + .create_disposable_context() + .map_err(|_| "fixture context creation must succeed")?; + bound + .destroy_disposable_context(&authority) + .map_err(|_| "fixture destruction must be proven")?; + assert!(bound.browser_session().recovery_evidence().is_empty()); + + assert!(bound.record_transport_loss()); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::TransportLost + ); + assert!(bound.browser_session().recovery_evidence().is_empty()); + assert!( + bound.into_recovery().is_err(), + "proven destruction must not be followed by a recovery-only adapter capability with no unresolved evidence" + ); + Ok(()) +} diff --git a/crates/originweave-browser-session/tests/recovery_operation_exact_fact_scope.rs b/crates/originweave-browser-session/tests/recovery_operation_exact_fact_scope.rs new file mode 100644 index 000000000..325080bdc --- /dev/null +++ b/crates/originweave-browser-session/tests/recovery_operation_exact_fact_scope.rs @@ -0,0 +1,231 @@ +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionRecoveryEvidence, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, RecoveryContextOperationError, RecoveryContextOperationPort, + RecoveryContextOperationRequest, RecoverySettlementPort, RecoverySettlementRequest, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecoveryOperation { + InspectSelectedFact, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FixtureError { + Rejected, +} + +struct ExactFactRecoveryPort { + create_results: VecDeque, + operation_calls: Rc>, + observed_recovery: Rc>>>, +} + +impl DisposableContextPort for ExactFactRecoveryPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.create_results + .pop_front() + .ok_or(DisposableContextCreateError::CreateFailedClean) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Err(DisposableContextDestroyError::DestroyFailed) + } +} + +impl RecoveryContextOperationPort for ExactFactRecoveryPort { + type Operation = RecoveryOperation; + type Output = (); + type Error = FixtureError; + + fn execute_recovery_context_operation( + &mut self, + request: &RecoveryContextOperationRequest, + ) -> Result { + self.operation_calls.set(self.operation_calls.get() + 1); + self.observed_recovery + .borrow_mut() + .push(request.recovery_evidence().cloned()); + if request.operation() != &RecoveryOperation::InspectSelectedFact { + return Err(FixtureError::Rejected); + } + Ok(()) + } +} + +impl RecoverySettlementPort for ExactFactRecoveryPort { + type Proof = (); + type Error = FixtureError; + + fn verify_recovery_settlement( + &mut self, + _request: &RecoverySettlementRequest, + ) -> Result<(), Self::Error> { + Ok(()) + } +} + +fn isolation(value: &str) -> Result { + DisposableIsolationId::parse(value).map_err(|_| "fixture isolation must be representable") +} + +fn context(value: u64) -> Result { + BrowsingContextId::new(value).map_err(|_| "fixture browsing context must be valid") +} + +fn session(value: u64) -> Result { + BrowserSessionId::new(value).map_err(|_| "fixture session must be valid") +} + +fn handle(isolation_id: &str, context_id: u64) -> Result { + Ok(DisposableContextHandle::new( + isolation(isolation_id)?, + context(context_id)?, + )) +} + +#[test] +fn recovery_operation_is_scoped_to_one_current_exact_fact() -> Result<(), &'static str> { + let first = handle("operation-fact-a", 91_001)?; + let second = handle("operation-fact-b", 91_002)?; + let operation_calls = Rc::new(Cell::new(0)); + let observed_recovery = Rc::new(RefCell::new(Vec::new())); + let port = ExactFactRecoveryPort { + create_results: VecDeque::from([first.clone(), second.clone()]), + operation_calls: Rc::clone(&operation_calls), + observed_recovery: Rc::clone(&observed_recovery), + }; + let mut bound = BrowserSession::start(session(9_101)?) + .map_err(|_| "browser session incarnation must be available")? + .bind_lifecycle_port(port); + let first_authority = bound + .create_disposable_context() + .map_err(|_| "first create must succeed")?; + let _second_authority = bound + .create_disposable_context() + .map_err(|_| "second create must succeed")?; + assert_eq!( + bound.destroy_disposable_context(&first_authority), + Err(BrowserSessionError::ContextDestructionFailed) + ); + + let mut recovery = bound + .into_recovery() + .map_err(|_| "destroy uncertainty must enter recovery custody")?; + assert_eq!(recovery.recovery_evidence().len(), 2); + let first_fact = recovery.recovery_fact(0).ok_or("first fact must exist")?; + let stale_sibling = recovery.recovery_fact(1).ok_or("sibling fact must exist")?; + + recovery + .execute_recovery_context_operation(first_fact, RecoveryOperation::InspectSelectedFact) + .map_err(|_| "current exact fact must authorize its bounded recovery operation")?; + assert_eq!(operation_calls.get(), 1); + assert_eq!(observed_recovery.borrow().len(), 1); + assert_eq!( + observed_recovery.borrow()[0].as_ref(), + recovery.recovery_evidence().first(), + "adapter request must expose only the selected recovery fact, not sibling uncertainty" + ); + + recovery + .settle_recovery_fact(first_fact, ()) + .map_err(|_| "first exact fact must settle")?; + assert_eq!( + recovery.execute_recovery_context_operation( + stale_sibling, + RecoveryOperation::InspectSelectedFact, + ), + Err(RecoveryContextOperationError::StaleFact), + "a fact issued before ledger mutation must fail before adapter I/O" + ); + assert_eq!(operation_calls.get(), 1); + + let current_sibling = recovery + .recovery_fact(0) + .ok_or("remaining sibling must be re-issued at the current revision")?; + recovery + .execute_recovery_context_operation( + current_sibling, + RecoveryOperation::InspectSelectedFact, + ) + .map_err(|_| "re-issued current sibling must reach the retained adapter")?; + assert_eq!(operation_calls.get(), 2); + assert_eq!(observed_recovery.borrow().len(), 2); + assert_eq!( + observed_recovery.borrow()[1].as_ref(), + recovery.recovery_evidence().first(), + ); + Ok(()) +} + +#[test] +fn foreign_recovery_fact_is_rejected_before_operation_io() -> Result<(), &'static str> { + fn recovering_session( + session_id: u64, + context_id: u64, + isolation_id: &str, + operation_calls: Rc>, + ) -> Result, &'static str> + { + let owned = handle(isolation_id, context_id)?; + let port = ExactFactRecoveryPort { + create_results: VecDeque::from([owned]), + operation_calls, + observed_recovery: Rc::new(RefCell::new(Vec::new())), + }; + let mut bound = BrowserSession::start(session(session_id)?) + .map_err(|_| "browser session incarnation must be available")? + .bind_lifecycle_port(port); + let authority = bound + .create_disposable_context() + .map_err(|_| "fixture create must succeed")?; + assert_eq!( + bound.destroy_disposable_context(&authority), + Err(BrowserSessionError::ContextDestructionFailed) + ); + bound + .into_recovery() + .map_err(|_| "fixture must enter recovery custody") + } + + let first_calls = Rc::new(Cell::new(0)); + let second_calls = Rc::new(Cell::new(0)); + let first = recovering_session(9_201, 92_001, "operation-foreign-a", first_calls)?; + let mut second = recovering_session( + 9_202, + 92_002, + "operation-foreign-b", + Rc::clone(&second_calls), + )?; + let foreign_fact = first.recovery_fact(0).ok_or("foreign fact must exist")?; + + assert_eq!( + second.execute_recovery_context_operation( + foreign_fact, + RecoveryOperation::InspectSelectedFact, + ), + Err(RecoveryContextOperationError::AuthorityMismatch) + ); + assert_eq!(second_calls.get(), 0); + Ok(()) +} diff --git a/crates/originweave-browser-session/tests/recovery_operation_terminal_closure.rs b/crates/originweave-browser-session/tests/recovery_operation_terminal_closure.rs new file mode 100644 index 000000000..dcf5168a8 --- /dev/null +++ b/crates/originweave-browser-session/tests/recovery_operation_terminal_closure.rs @@ -0,0 +1,126 @@ +use std::cell::Cell; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionState, DisposableContextCreateCompletion, + DisposableContextCreateCompletionError, DisposableContextCreateError, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, + RecoveryContextOperationError, RecoveryContextOperationPort, RecoveryContextOperationRequest, + RecoverySettlementPort, RecoverySettlementRequest, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +struct RecoveryPort { + context: DisposableContextHandle, + recovery_operation_calls: Rc>, +} + +impl DisposableContextPort for RecoveryPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + Ok(self.context.clone()) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Err(DisposableContextDestroyError::DestroyFailed) + } +} + +impl RecoveryContextOperationPort for RecoveryPort { + type Operation = (); + type Output = (); + type Error = (); + + fn execute_recovery_context_operation( + &mut self, + _request: &RecoveryContextOperationRequest, + ) -> Result { + self.recovery_operation_calls + .set(self.recovery_operation_calls.get() + 1); + Ok(()) + } +} + +impl RecoverySettlementPort for RecoveryPort { + type Proof = (); + type Error = (); + + fn verify_recovery_settlement( + &mut self, + _request: &RecoverySettlementRequest, + ) -> Result<(), Self::Error> { + Ok(()) + } +} + +fn session(value: u64) -> Result { + BrowserSessionId::new(value).map_err(|_| "fixture browser session must be valid") +} + +fn context(value: u64) -> Result { + BrowsingContextId::new(value).map_err(|_| "fixture browsing context must be valid") +} + +#[test] +fn terminal_recovery_settlement_revokes_generic_recovery_io() -> Result<(), &'static str> { + let recovery_operation_calls = Rc::new(Cell::new(0)); + let port = RecoveryPort { + context: DisposableContextHandle::new( + DisposableIsolationId::parse("terminal-recovery-context") + .map_err(|_| "fixture isolation must be representable")?, + context(91_001)?, + ), + recovery_operation_calls: Rc::clone(&recovery_operation_calls), + }; + let mut bound = BrowserSession::start(session(9_101)?) + .map_err(|_| "browser session incarnation must be available")? + .bind_lifecycle_port(port); + let authority = bound + .create_disposable_context() + .map_err(|_| "fixture create must succeed")?; + assert_eq!( + bound.destroy_disposable_context(&authority), + Err(BrowserSessionError::ContextDestructionFailed) + ); + + let mut recovery = bound + .into_recovery() + .map_err(|_| "unproven destruction must enter recovery custody")?; + let fact = recovery + .recovery_fact(0) + .ok_or("unproven destruction recovery fact must exist")?; + recovery + .execute_recovery_context_operation(fact, ()) + .map_err(|_| "recovery operation must be available while uncertainty remains")?; + assert_eq!(recovery_operation_calls.get(), 1); + + recovery + .settle_recovery_fact(fact, ()) + .map_err(|_| "independently verified recovery fact must settle")?; + assert_eq!(recovery.state(), BrowserSessionState::Ended); + + assert_eq!( + recovery.execute_recovery_context_operation(fact, ()), + Err(RecoveryContextOperationError::RecoveryClosed), + "terminal recovery custody must not retain a generic adapter-I/O capability" + ); + assert_eq!( + recovery_operation_calls.get(), + 1, + "terminal-state rejection must happen before retained-adapter I/O" + ); + Ok(()) +} diff --git a/crates/originweave-browser-session/tests/recovery_owner_handoff.rs b/crates/originweave-browser-session/tests/recovery_owner_handoff.rs new file mode 100644 index 000000000..063489ea7 --- /dev/null +++ b/crates/originweave-browser-session/tests/recovery_owner_handoff.rs @@ -0,0 +1,201 @@ +use std::cell::Cell; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionRecoveryEvidence, BrowserSessionState, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +struct RecoveryTrackedPort { + handle: DisposableContextHandle, + fail_destroy: bool, + create_calls: Rc>, + destroy_calls: Rc>, + drop_calls: Rc>, +} + +impl Drop for RecoveryTrackedPort { + fn drop(&mut self) { + self.drop_calls.set(self.drop_calls.get() + 1); + } +} + +impl DisposableContextPort for RecoveryTrackedPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.create_calls.set(self.create_calls.get() + 1); + Ok(self.handle.clone()) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_calls.set(self.destroy_calls.get() + 1); + if self.fail_destroy { + Err(DisposableContextDestroyError::DestroyFailed) + } else { + Ok(()) + } + } +} + +fn recovery_port( + context: u64, + isolation: &str, + fail_destroy: bool, + create_calls: Rc>, + destroy_calls: Rc>, + drop_calls: Rc>, +) -> Result { + let isolation = DisposableIsolationId::parse(isolation) + .map_err(|_| "static fixture isolation id must be valid")?; + let browsing_context = BrowsingContextId::new(context) + .map_err(|_| "static fixture browsing context id must be valid")?; + Ok(RecoveryTrackedPort { + handle: DisposableContextHandle::new(isolation, browsing_context), + fail_destroy, + create_calls, + destroy_calls, + drop_calls, + }) +} + +#[test] +fn unproven_destroy_hands_exact_bound_adapter_and_evidence_to_recovery_owner( +) -> Result<(), &'static str> { + let create_calls = Rc::new(Cell::new(0)); + let destroy_calls = Rc::new(Cell::new(0)); + let drop_calls = Rc::new(Cell::new(0)); + let session = BrowserSession::start( + BrowserSessionId::new(710).map_err(|_| "static session id must be valid")?, + ) + .map_err(|_| "browser session incarnation must be available")?; + let port = recovery_port( + 7_100, + "recovery-handoff-destroy", + true, + Rc::clone(&create_calls), + Rc::clone(&destroy_calls), + Rc::clone(&drop_calls), + )?; + let mut bound = session.bind_lifecycle_port(port); + + let authority = bound + .create_disposable_context() + .map_err(|_| "fixture context creation must succeed")?; + assert_eq!( + bound.destroy_disposable_context(&authority), + Err(BrowserSessionError::ContextDestructionFailed) + ); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + let expected_evidence = bound.browser_session().recovery_evidence().to_vec(); + assert!(matches!( + expected_evidence.as_slice(), + [BrowserSessionRecoveryEvidence::UnprovenDestruction { .. }] + )); + assert_eq!(create_calls.get(), 1); + assert_eq!(destroy_calls.get(), 1); + assert_eq!(drop_calls.get(), 0); + + let recovery = bound + .into_recovery() + .map_err(|_| "RecoveryRequired must permit consuming recovery handoff")?; + + assert_eq!( + drop_calls.get(), + 0, + "handoff must move, not replace, the bound adapter" + ); + assert_eq!(recovery.state(), BrowserSessionState::RecoveryRequired); + assert_eq!(recovery.recovery_evidence(), expected_evidence); + assert!(recovery.create_attempt_recovery_evidence().is_empty()); + assert_eq!(create_calls.get(), 1, "handoff must not create browser state"); + assert_eq!(destroy_calls.get(), 1, "handoff must not imply cleanup I/O"); + + drop(recovery); + assert_eq!( + drop_calls.get(), + 1, + "the exact non-Clone adapter must stay alive until the recovery owner is dropped" + ); + Ok(()) +} + +#[test] +fn transport_loss_hands_exact_bound_adapter_and_evidence_to_recovery_owner( +) -> Result<(), &'static str> { + let create_calls = Rc::new(Cell::new(0)); + let destroy_calls = Rc::new(Cell::new(0)); + let drop_calls = Rc::new(Cell::new(0)); + let session = BrowserSession::start( + BrowserSessionId::new(711).map_err(|_| "static session id must be valid")?, + ) + .map_err(|_| "browser session incarnation must be available")?; + let port = recovery_port( + 7_110, + "recovery-handoff-transport", + false, + Rc::clone(&create_calls), + Rc::clone(&destroy_calls), + Rc::clone(&drop_calls), + )?; + let mut bound = session.bind_lifecycle_port(port); + + let authority = bound + .create_disposable_context() + .map_err(|_| "fixture context creation must succeed")?; + assert_eq!(authority.browsing_context().value(), 7_110); + assert!(bound.record_transport_loss()); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::TransportLost + ); + let expected_evidence = bound.browser_session().recovery_evidence().to_vec(); + assert!(matches!( + expected_evidence.as_slice(), + [BrowserSessionRecoveryEvidence::TransportLossOwnedHandle(_)] + )); + assert_eq!(create_calls.get(), 1); + assert_eq!(destroy_calls.get(), 0); + assert_eq!(drop_calls.get(), 0); + + let recovery = bound + .into_recovery() + .map_err(|_| "TransportLost must permit consuming recovery handoff")?; + + assert_eq!( + drop_calls.get(), + 0, + "handoff must preserve the same bound adapter instance" + ); + assert_eq!(recovery.state(), BrowserSessionState::TransportLost); + assert_eq!(recovery.recovery_evidence(), expected_evidence); + assert!(recovery.create_attempt_recovery_evidence().is_empty()); + assert_eq!(create_calls.get(), 1, "handoff must not create browser state"); + assert_eq!(destroy_calls.get(), 0, "transport loss is not destruction proof"); + + drop(recovery); + assert_eq!( + drop_calls.get(), + 1, + "the exact non-Clone adapter must remain owned by the recovery wrapper until drop" + ); + Ok(()) +} diff --git a/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs b/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs new file mode 100644 index 000000000..741bd015f --- /dev/null +++ b/crates/originweave-browser-session/tests/recovery_required_sibling_evidence.rs @@ -0,0 +1,121 @@ +use std::collections::VecDeque; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionRecoveryEvidence, BrowserSessionState, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +struct FailingDestroyPort { + handles: VecDeque, +} + +impl DisposableContextPort for FailingDestroyPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.handles + .pop_front() + .ok_or(DisposableContextCreateError::CreateFailedClean) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + Err(DisposableContextDestroyError::DestroyFailed) + } +} + +fn handle(context: u64, isolation: &str) -> DisposableContextHandle { + DisposableContextHandle::new( + DisposableIsolationId::parse(isolation).expect("valid isolation id"), + BrowsingContextId::new(context).expect("valid browsing context"), + ) +} + +fn existing_exact_handle( + evidence: &BrowserSessionRecoveryEvidence, +) -> Option<&DisposableContextHandle> { + match evidence { + BrowserSessionRecoveryEvidence::DuplicateAdapterHandle(handle) + | BrowserSessionRecoveryEvidence::UnsettledAdapterHandle(handle) + | BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(handle) + | BrowserSessionRecoveryEvidence::TransportLossOwnedHandle(handle) => Some(handle), + BrowserSessionRecoveryEvidence::UnprovenDestruction { context, .. } => Some(context), + BrowserSessionRecoveryEvidence::PartialCreationIsolation(_) => None, + } +} + +#[test] +fn recovery_required_projects_exact_handles_for_indirectly_uncertain_siblings() { + let first = handle(5070, "recovery-user-context-a"); + let sibling = handle(5071, "recovery-user-context-b"); + let port = FailingDestroyPort { + handles: VecDeque::from([first.clone(), sibling.clone()]), + }; + let session = BrowserSession::start(BrowserSessionId::new(507).expect("valid session id")) + .expect("incarnation capacity"); + let mut bound = session.bind_lifecycle_port(port); + + let first_authority = bound + .create_disposable_context() + .expect("first accepted context"); + let first_epoch = first_authority.context_epoch(); + let _sibling_authority = bound + .create_disposable_context() + .expect("second accepted context"); + + assert_eq!( + bound.destroy_disposable_context(&first_authority), + Err(BrowserSessionError::ContextDestructionFailed) + ); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::RecoveryRequired + ); + + let evidence = bound.browser_session().recovery_evidence(); + assert!( + evidence.contains(&BrowserSessionRecoveryEvidence::UnprovenDestruction { + context: first.clone(), + context_epoch: first_epoch, + }), + "the directly failed destruction must keep its cause-specific handle and validated epoch" + ); + assert!( + evidence.contains( + &BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle(sibling.clone()) + ), + "the indirectly invalidated sibling must be projected as non-authorizing exact recovery evidence" + ); + assert_eq!( + evidence + .iter() + .filter_map(existing_exact_handle) + .filter(|candidate| *candidate == &first) + .count(), + 1, + "the directly failed context must not be duplicated as generic recovery evidence" + ); + assert_eq!( + evidence + .iter() + .filter_map(existing_exact_handle) + .filter(|candidate| *candidate == &sibling) + .count(), + 1, + "an indirectly invalidated sibling must be retained exactly once" + ); +} diff --git a/crates/originweave-browser-session/tests/recovery_same_adapter_operation.rs b/crates/originweave-browser-session/tests/recovery_same_adapter_operation.rs new file mode 100644 index 000000000..f23c697a3 --- /dev/null +++ b/crates/originweave-browser-session/tests/recovery_same_adapter_operation.rs @@ -0,0 +1,312 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionError, BrowserSessionRecoveryEvidence, BrowserSessionState, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRecoveryEvidence, + DisposableContextCreateRequest, DisposableContextDestroyError, DisposableContextDestroyRequest, + DisposableContextHandle, DisposableContextPort, DisposableIsolationId, + RecoveryContextOperationError, RecoveryContextOperationPort, RecoveryContextOperationRequest, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecoveryOperation { + ReconcileExactEvidence, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecoveryOperationFailure { + BackendUnavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RecoveryObservation { + browser_session: BrowserSessionId, + incarnation: u64, + state: BrowserSessionState, + recovery_evidence: Option, + create_attempt_recovery_evidence: Option, + operation: RecoveryOperation, +} + +struct RecoveryPort { + handle: DisposableContextHandle, + fail_create_uncertain: bool, + fail_destroy: bool, + fail_recovery: Rc>, + recovery_calls: Rc>, + observations: Rc>>, +} + +impl DisposableContextPort for RecoveryPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + if self.fail_create_uncertain { + Err(DisposableContextCreateError::CreateFailedUncertain(Some( + self.handle.isolation().clone(), + ))) + } else { + Ok(self.handle.clone()) + } + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + if self.fail_destroy { + Err(DisposableContextDestroyError::DestroyFailed) + } else { + Ok(()) + } + } +} + +impl RecoveryContextOperationPort for RecoveryPort { + type Operation = RecoveryOperation; + type Output = (); + type Error = RecoveryOperationFailure; + + fn execute_recovery_context_operation( + &mut self, + request: &RecoveryContextOperationRequest, + ) -> Result { + self.recovery_calls.set(self.recovery_calls.get() + 1); + self.observations.borrow_mut().push(RecoveryObservation { + browser_session: request.browser_session(), + incarnation: request.incarnation().value(), + state: request.state(), + recovery_evidence: request.recovery_evidence().cloned(), + create_attempt_recovery_evidence: request + .create_attempt_recovery_evidence() + .cloned(), + operation: *request.operation(), + }); + if self.fail_recovery.get() { + Err(RecoveryOperationFailure::BackendUnavailable) + } else { + Ok(()) + } + } +} + +fn isolation(value: &str) -> Result { + DisposableIsolationId::parse(value).map_err(|_| "fixture isolation must be representable") +} + +fn context(value: u64) -> Result { + BrowsingContextId::new(value).map_err(|_| "fixture browsing context must be valid") +} + +fn session(value: u64) -> Result { + BrowserSessionId::new(value).map_err(|_| "fixture session must be valid") +} + +#[test] +fn recovery_custody_routes_only_purpose_bounded_io_to_the_exact_consumed_adapter( +) -> Result<(), &'static str> { + let fail_recovery = Rc::new(Cell::new(true)); + let recovery_calls = Rc::new(Cell::new(0)); + let observations = Rc::new(RefCell::new(Vec::new())); + let expected_session = session(7_901)?; + let expected_handle = DisposableContextHandle::new( + isolation("same-adapter-recovery-user-context")?, + context(79_010)?, + ); + let port = RecoveryPort { + handle: expected_handle.clone(), + fail_create_uncertain: false, + fail_destroy: true, + fail_recovery: Rc::clone(&fail_recovery), + recovery_calls: Rc::clone(&recovery_calls), + observations: Rc::clone(&observations), + }; + let mut bound = BrowserSession::start(expected_session) + .map_err(|_| "browser session incarnation must be available")? + .bind_lifecycle_port(port); + let authority = bound + .create_disposable_context() + .map_err(|_| "fixture create must succeed")?; + let expected_incarnation = bound.browser_session().incarnation(); + assert_eq!( + bound.destroy_disposable_context(&authority), + Err(BrowserSessionError::ContextDestructionFailed) + ); + let expected_recovery_evidence = bound.browser_session().recovery_evidence().to_vec(); + assert_eq!( + expected_recovery_evidence, + vec![BrowserSessionRecoveryEvidence::UnprovenDestruction { + context: expected_handle, + context_epoch: authority.context_epoch(), + }] + ); + let expected_create_attempt_recovery_evidence = bound + .browser_session() + .create_attempt_recovery_evidence() + .to_vec(); + + let mut recovery = bound + .into_recovery() + .map_err(|_| "RecoveryRequired must enter recovery custody")?; + let fact = recovery + .recovery_fact(0) + .ok_or("exact recovery fact must be addressable")?; + + assert_eq!( + recovery.execute_recovery_context_operation( + fact, + RecoveryOperation::ReconcileExactEvidence, + ), + Err(RecoveryContextOperationError::Adapter( + RecoveryOperationFailure::BackendUnavailable + )) + ); + assert_eq!(recovery_calls.get(), 1); + assert_eq!(recovery.state(), BrowserSessionState::RecoveryRequired); + assert_eq!(recovery.recovery_evidence(), expected_recovery_evidence); + assert_eq!( + recovery.create_attempt_recovery_evidence(), + expected_create_attempt_recovery_evidence, + "recovery operation dispatch must not erase create-attempt provenance" + ); + + let first = observations.borrow(); + assert_eq!(first.len(), 1); + assert_eq!(first[0].browser_session, expected_session); + assert_eq!(first[0].incarnation, expected_incarnation.value()); + assert_eq!(first[0].state, BrowserSessionState::RecoveryRequired); + assert_eq!( + first[0].recovery_evidence.as_ref(), + expected_recovery_evidence.first(), + "request must expose only the selected exact recovery fact" + ); + assert_eq!(first[0].create_attempt_recovery_evidence, None); + assert_eq!( + first[0].operation, + RecoveryOperation::ReconcileExactEvidence + ); + drop(first); + + fail_recovery.set(false); + recovery + .execute_recovery_context_operation(fact, RecoveryOperation::ReconcileExactEvidence) + .map_err(|_| "purpose-bounded recovery operation must reach the retained adapter")?; + assert_eq!(recovery_calls.get(), 2); + assert_eq!( + recovery.state(), + BrowserSessionState::RecoveryRequired, + "generic recovery adapter success is not itself destruction or reconciliation proof" + ); + assert_eq!( + recovery.recovery_evidence(), + expected_recovery_evidence, + "recovery operation dispatch must not erase unresolved ownership evidence" + ); + assert_eq!( + recovery.create_attempt_recovery_evidence(), + expected_create_attempt_recovery_evidence, + "recovery operation dispatch must not erase create-attempt provenance" + ); + Ok(()) +} + +#[test] +fn recovery_dispatch_preserves_non_empty_create_attempt_provenance_on_failure_and_success( +) -> Result<(), &'static str> { + let fail_recovery = Rc::new(Cell::new(true)); + let recovery_calls = Rc::new(Cell::new(0)); + let observations = Rc::new(RefCell::new(Vec::new())); + let expected_session = session(7_902)?; + let expected_handle = DisposableContextHandle::new( + isolation("same-adapter-uncertain-create")?, + context(79_020)?, + ); + let port = RecoveryPort { + handle: expected_handle, + fail_create_uncertain: true, + fail_destroy: false, + fail_recovery: Rc::clone(&fail_recovery), + recovery_calls: Rc::clone(&recovery_calls), + observations: Rc::clone(&observations), + }; + let mut bound = BrowserSession::start(expected_session) + .map_err(|_| "browser session incarnation must be available")? + .bind_lifecycle_port(port); + let expected_incarnation = bound.browser_session().incarnation(); + + assert!(matches!( + bound.create_disposable_context(), + Err(BrowserSessionError::ContextCreationUncertain) + )); + let expected_recovery_evidence = bound.browser_session().recovery_evidence().to_vec(); + let expected_create_attempt_recovery_evidence = bound + .browser_session() + .create_attempt_recovery_evidence() + .to_vec(); + assert!(!expected_recovery_evidence.is_empty()); + assert!(matches!( + expected_create_attempt_recovery_evidence.as_slice(), + [DisposableContextCreateRecoveryEvidence::FailedUncertain { .. }] + )); + + let mut recovery = bound + .into_recovery() + .map_err(|_| "uncertain create must enter recovery custody")?; + let fact = recovery + .create_attempt_recovery_fact(0) + .ok_or("exact create-attempt recovery fact must be addressable")?; + assert_eq!( + recovery.execute_recovery_context_operation( + fact, + RecoveryOperation::ReconcileExactEvidence, + ), + Err(RecoveryContextOperationError::Adapter( + RecoveryOperationFailure::BackendUnavailable + )) + ); + assert_eq!(recovery.state(), BrowserSessionState::RecoveryRequired); + assert_eq!(recovery.recovery_evidence(), expected_recovery_evidence); + assert_eq!( + recovery.create_attempt_recovery_evidence(), + expected_create_attempt_recovery_evidence, + "recovery operation dispatch must not erase create-attempt provenance" + ); + + let first = observations.borrow(); + assert_eq!(first.len(), 1); + assert_eq!(first[0].browser_session, expected_session); + assert_eq!(first[0].incarnation, expected_incarnation.value()); + assert_eq!(first[0].state, BrowserSessionState::RecoveryRequired); + assert_eq!(first[0].recovery_evidence, None); + assert_eq!( + first[0].create_attempt_recovery_evidence.as_ref(), + expected_create_attempt_recovery_evidence.first(), + "request must expose only the selected exact create-attempt fact" + ); + drop(first); + + fail_recovery.set(false); + recovery + .execute_recovery_context_operation(fact, RecoveryOperation::ReconcileExactEvidence) + .map_err(|_| "recovery success must use the retained adapter")?; + assert_eq!(recovery_calls.get(), 2); + assert_eq!(recovery.state(), BrowserSessionState::RecoveryRequired); + assert_eq!(recovery.recovery_evidence(), expected_recovery_evidence); + assert_eq!( + recovery.create_attempt_recovery_evidence(), + expected_create_attempt_recovery_evidence, + "recovery operation dispatch must not erase create-attempt provenance" + ); + Ok(()) +} diff --git a/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs index 355201280..81eac3a9e 100644 --- a/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs +++ b/crates/originweave-browser-session/tests/sequential_incarnation_reuse.rs @@ -1,6 +1,11 @@ +use std::cell::RefCell; +use std::rc::Rc; + use originweave_browser_session::{ - BrowserSession, BrowserSessionError, BrowserSessionIncarnation, DisposableContextCreateError, - DisposableContextDestroyError, DisposableContextHandle, DisposableContextPort, + BrowserSession, BrowserSessionError, BrowserSessionIncarnation, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, DisposableIsolationId, }; use originweave_core::{BrowserSessionId, BrowsingContextId}; @@ -8,20 +13,25 @@ use originweave_core::{BrowserSessionId, BrowsingContextId}; #[derive(Debug)] struct ReusingPort { handle: DisposableContextHandle, - create_incarnations: Vec, - destroy_incarnations: Vec, + create_incarnations: Rc>>, + destroy_incarnations: Rc>>, } impl ReusingPort { - fn new(context: u64, isolation: &str) -> Result { + fn new( + context: u64, + isolation: &str, + create_incarnations: Rc>>, + destroy_incarnations: Rc>>, + ) -> Result { let isolation = DisposableIsolationId::parse(isolation) .map_err(|_| "static fixture isolation id must be valid")?; let browsing_context = BrowsingContextId::new(context) .map_err(|_| "static fixture browsing context id must be valid")?; Ok(Self { handle: DisposableContextHandle::new(isolation, browsing_context), - create_incarnations: Vec::new(), - destroy_incarnations: Vec::new(), + create_incarnations, + destroy_incarnations, }) } } @@ -29,20 +39,28 @@ impl ReusingPort { impl DisposableContextPort for ReusingPort { fn create_disposable_context( &mut self, - _browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, + request: &DisposableContextCreateRequest, ) -> Result { - self.create_incarnations.push(incarnation); + self.create_incarnations + .borrow_mut() + .push(request.incarnation()); Ok(self.handle.clone()) } + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + Ok(()) + } + fn destroy_disposable_context( &mut self, - _browser_session: BrowserSessionId, - incarnation: BrowserSessionIncarnation, - _context: &DisposableContextHandle, + request: &DisposableContextDestroyRequest, ) -> Result<(), DisposableContextDestroyError> { - self.destroy_incarnations.push(incarnation); + self.destroy_incarnations + .borrow_mut() + .push(request.incarnation()); Ok(()) } } @@ -53,38 +71,64 @@ fn stale_authority_cannot_cross_sequential_session_incarnations() -> Result<(), let session_id = BrowserSessionId::new(701) .map_err(|_| "static fixture browser session id must be valid")?; - let mut port_a = ReusingPort::new(7010, "user-context-reused")?; - let mut session_a = BrowserSession::start(session_id) + let create_a = Rc::new(RefCell::new(Vec::new())); + let destroy_a = Rc::new(RefCell::new(Vec::new())); + let session_a = BrowserSession::start(session_id) .map_err(|_| "first browser session incarnation must be available")?; - let authority_a = session_a - .create_disposable_context(&mut port_a) + let mut bound_a = session_a.bind_lifecycle_port(ReusingPort::new( + 7010, + "user-context-reused", + Rc::clone(&create_a), + Rc::clone(&destroy_a), + )?); + let authority_a = bound_a + .create_disposable_context() .map_err(|_| "first disposable context creation must succeed")?; - session_a - .destroy_disposable_context(&authority_a, &mut port_a) + bound_a + .destroy_disposable_context(&authority_a) .map_err(|_| "first disposable context destruction must succeed")?; - session_a + bound_a .end() .map_err(|_| "first browser session must end normally")?; - let mut port_b = ReusingPort::new(7010, "user-context-reused")?; - let mut session_b = BrowserSession::start(session_id) + let create_b = Rc::new(RefCell::new(Vec::new())); + let destroy_b = Rc::new(RefCell::new(Vec::new())); + let session_b = BrowserSession::start(session_id) .map_err(|_| "second browser session incarnation must be available")?; - let authority_b = session_b - .create_disposable_context(&mut port_b) + let mut bound_b = session_b.bind_lifecycle_port(ReusingPort::new( + 7010, + "user-context-reused", + Rc::clone(&create_b), + Rc::clone(&destroy_b), + )?); + let authority_b = bound_b + .create_disposable_context() .map_err(|_| "second disposable context creation must succeed")?; - assert_ne!(session_a.incarnation(), session_b.incarnation()); - assert_eq!(port_a.create_incarnations, vec![session_a.incarnation()]); - assert_eq!(port_b.create_incarnations, vec![session_b.incarnation()]); + assert_ne!( + bound_a.browser_session().incarnation(), + bound_b.browser_session().incarnation() + ); assert_eq!( - session_b.destroy_disposable_context(&authority_a, &mut port_b), + create_a.borrow().as_slice(), + &[bound_a.browser_session().incarnation()] + ); + assert_eq!( + create_b.borrow().as_slice(), + &[bound_b.browser_session().incarnation()] + ); + assert_eq!( + bound_b.destroy_disposable_context(&authority_a), Err(BrowserSessionError::AuthorityMismatch) ); - assert!(port_b.destroy_incarnations.is_empty()); + assert!(destroy_b.borrow().is_empty()); - session_b - .destroy_disposable_context(&authority_b, &mut port_b) + bound_b + .destroy_disposable_context(&authority_b) .map_err(|_| "current incarnation authority must remain valid")?; - assert_eq!(port_b.destroy_incarnations, vec![session_b.incarnation()]); + assert_eq!( + destroy_b.borrow().as_slice(), + &[bound_b.browser_session().incarnation()] + ); Ok(()) } diff --git a/crates/originweave-browser-session/tests/transport_loss_recovery_evidence.rs b/crates/originweave-browser-session/tests/transport_loss_recovery_evidence.rs new file mode 100644 index 000000000..5d0a9677f --- /dev/null +++ b/crates/originweave-browser-session/tests/transport_loss_recovery_evidence.rs @@ -0,0 +1,116 @@ +use std::cell::Cell; +use std::rc::Rc; + +use originweave_browser_session::{ + BrowserSession, BrowserSessionRecoveryEvidence, BrowserSessionState, + DisposableContextCreateCompletion, DisposableContextCreateCompletionError, + DisposableContextCreateError, DisposableContextCreateRequest, DisposableContextDestroyError, + DisposableContextDestroyRequest, DisposableContextHandle, DisposableContextPort, + DisposableIsolationId, +}; +use originweave_core::{BrowserSessionId, BrowsingContextId}; + +struct ObservedPort { + create_calls: Rc>, + completion_calls: Rc>, + destroy_calls: Rc>, +} + +impl DisposableContextPort for ObservedPort { + fn create_disposable_context( + &mut self, + _request: &DisposableContextCreateRequest, + ) -> Result { + self.create_calls.set(self.create_calls.get() + 1); + Ok(DisposableContextHandle::new( + DisposableIsolationId::parse("transport-user-context-501").expect("valid isolation id"), + BrowsingContextId::new(501).expect("valid browsing context"), + )) + } + + fn complete_disposable_context_creation( + &mut self, + _completion: &DisposableContextCreateCompletion, + ) -> Result<(), DisposableContextCreateCompletionError> { + self.completion_calls.set(self.completion_calls.get() + 1); + Ok(()) + } + + fn destroy_disposable_context( + &mut self, + _request: &DisposableContextDestroyRequest, + ) -> Result<(), DisposableContextDestroyError> { + self.destroy_calls.set(self.destroy_calls.get() + 1); + Ok(()) + } +} + +#[test] +fn transport_loss_preserves_exact_owned_handle_as_non_authorizing_recovery_evidence() { + let create_calls = Rc::new(Cell::new(0)); + let completion_calls = Rc::new(Cell::new(0)); + let destroy_calls = Rc::new(Cell::new(0)); + let port = ObservedPort { + create_calls: Rc::clone(&create_calls), + completion_calls: Rc::clone(&completion_calls), + destroy_calls: Rc::clone(&destroy_calls), + }; + let session = BrowserSession::start(BrowserSessionId::new(501).expect("valid session id")) + .expect("incarnation capacity"); + let mut bound = session.bind_lifecycle_port(port); + + let authority = bound + .create_disposable_context() + .expect("accepted disposable context"); + assert_eq!(authority.browsing_context().value(), 501); + assert_eq!(create_calls.get(), 1); + assert_eq!(completion_calls.get(), 1); + assert_eq!(destroy_calls.get(), 0); + + assert!(bound.record_transport_loss()); + assert_eq!( + bound.browser_session().state(), + BrowserSessionState::TransportLost + ); + assert_eq!( + create_calls.get(), + 1, + "transport loss must not create browser state" + ); + assert_eq!( + completion_calls.get(), + 1, + "transport loss must not settle another create attempt" + ); + assert_eq!( + destroy_calls.get(), + 0, + "transport loss is not destruction proof" + ); + + let evidence = bound.browser_session().recovery_evidence(); + assert_eq!( + evidence, + &[BrowserSessionRecoveryEvidence::TransportLossOwnedHandle( + DisposableContextHandle::new( + DisposableIsolationId::parse("transport-user-context-501") + .expect("valid isolation id"), + BrowsingContextId::new(501).expect("valid browsing context"), + ), + )], + "transport loss must retain the exact identity-bearing evidence discriminator and handle" + ); + + assert!(!bound.record_transport_loss()); + assert_eq!( + bound.browser_session().recovery_evidence().len(), + 1, + "repeated transport-loss reports must not duplicate recovery evidence" + ); + assert_eq!( + bound.presentation_authority(authority.browsing_context()), + Err(originweave_browser_session::BrowserSessionError::SessionNotActive), + "transport-loss recovery evidence must never resurrect presentation authority" + ); + assert_eq!(destroy_calls.get(), 0); +} diff --git a/crates/originweave-browser-session/tests/user_context_identity_length.rs b/crates/originweave-browser-session/tests/user_context_identity_length.rs new file mode 100644 index 000000000..37c5ddcdc --- /dev/null +++ b/crates/originweave-browser-session/tests/user_context_identity_length.rs @@ -0,0 +1,19 @@ +use originweave_browser_session::DisposableIsolationId; + +#[test] +fn webdriver_bidi_user_context_preserves_protocol_text_without_domain_grammar() { + let cases = [ + String::new(), + " context ".to_owned(), + "ctx\n".to_owned(), + "u".repeat(4097), + ]; + + for remote_user_context in cases { + let identity = DisposableIsolationId::parse(&remote_user_context).expect( + "WebDriver BiDi browser.UserContext is CDDL text; Browser Session must preserve the exact remote identity", + ); + + assert_eq!(identity.as_str(), remote_user_context); + } +} diff --git a/docs/adr/0107-browser-protocol-adapter-strategy.md b/docs/adr/0107-browser-protocol-adapter-strategy.md index 491359110..6eb503049 100644 --- a/docs/adr/0107-browser-protocol-adapter-strategy.md +++ b/docs/adr/0107-browser-protocol-adapter-strategy.md @@ -44,7 +44,7 @@ Neither protected main nor PR #170 implements Streamable HTTP transport parsing, The version boundary is explicit: the protected-main routing foundation and active discovery refinement accept only MCP `2026-07-28`; neither infers compatibility with later protocol generations. OriginWeave Protocol versioning remains independent and cannot be changed by MCP metadata. -PR #293 was merged into PR #229 on 2026-09-09, so its `originweave-bidi` capability boundary is inherited by this parent rather than remaining a separate active stacked slice. The adapter remains runtime-qualified 3 September 2026 against the immutable WebDriver BiDi Working Draft URI `https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/`. W3C has since published the latest published 9 September 2026 Working Draft; publication freshness is recorded separately in `docs/traceability/webdriver-bidi-publication-current.md` and does not silently repin runtime compatibility. A newer runtime pin requires a dedicated compatibility/conformance change and pinned-browser evidence. +PR #293 was merged into PR #229 on 2026-09-09, so its `originweave-bidi` capability boundary is inherited by this parent rather than remaining a separate active stacked slice. The adapter remains runtime-qualified 3 September 2026 against the immutable WebDriver BiDi Working Draft URI `https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/`. W3C has since published the latest published 16 September 2026 Working Draft, with 14 September 2026 as the previous published version; publication freshness is recorded separately in `docs/traceability/webdriver-bidi-publication-current.md` and does not silently repin runtime compatibility. The mutable Editor's Draft remains a separate research surface. A newer runtime pin requires a dedicated compatibility/conformance change and pinned-browser evidence. The inherited capability map delegates complete-profile admission to `originweave-fingerprint` and intentionally excludes `Screen`, `Languages`, `HardwareConcurrency`, and `Platform`. The standard screen-settings command omits color depth and, importantly, applies one rectangle to both the web-exposed total screen area and available screen area, while the current OriginWeave presentation profile does not model the available-screen rectangle. The locale command likewise cannot prove ordered language preferences. Standard BiDi alone must therefore return the kernel's first `MissingSurface(Screen)` result rather than accept ambient host values. @@ -96,7 +96,9 @@ Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://mo Parra, D. S., & Delimarsky, D. (2026, July 28). *The 2026-07-28 specification*. Model Context Protocol Blog. https://blog.modelcontextprotocol.io/posts/2026-07-28/ -World Wide Web Consortium. (2026, September 9). *WebDriver BiDi* [Working Draft; latest publication observed 2026-09-10]. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/ +World Wide Web Consortium. (2026, September 16). *WebDriver BiDi* [Working Draft; latest publication observed 2026-09-16]. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260916/ + +World Wide Web Consortium. (2026, September 14). *WebDriver BiDi* [Working Draft; previous published version]. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260914/ World Wide Web Consortium. (2026, September 3). *WebDriver BiDi* [Working Draft; runtime-qualified OriginWeave adapter pin]. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/ diff --git a/docs/adr/0114-browser-session-disposable-context-authority.md b/docs/adr/0114-browser-session-disposable-context-authority.md index 345071fd6..8e3562dbb 100644 --- a/docs/adr/0114-browser-session-disposable-context-authority.md +++ b/docs/adr/0114-browser-session-disposable-context-authority.md @@ -2,125 +2,189 @@ - Status: Proposed - Date: 2026-09-10 +- Last code-current review: 2026-09-17 ## Context -OriginWeave's WebDriver BiDi presentation adapter requires opaque ownership witnesses before viewport/device-pixel-ratio, timezone, or screen-area mutation can be planned. A caller that merely knows a browser-session or browsing-context identifier therefore cannot overwrite another owner's presentation state and later clear it to an implementation default. +OriginWeave's Browser Session bounded context is the domain authority for disposable browser lifecycle ownership and presentation mutation. WebDriver BiDi session ids, user-context ids, browsing-context ids, and adapter-selected values are protocol addressability, not authorization. -The Browser Session boundary must establish why a context is exclusively OriginWeave-owned before presentation authority exists. External browser-session, user-context/isolation, and browsing-context identifiers are protocol addressability. They may be reused after a prior lifecycle ends, so `(BrowserSessionId, DisposableIsolationId, BrowsingContextId, local epoch)` is not by itself a durable capability generation. +The active implementation has to satisfy four constraints at once. First, `BoundBrowserSession

` must consume the one concrete lifecycle adapter without later exposing raw `&P`/`&mut P` or a replacement-port path. Second, one Browser Session incarnation can issue multiple remote creates, so each result requires an aggregate-issued per-create transaction identity before it may become authorizing and every uncertain create outcome must retain that identity for recovery. Third, dependent WebDriver BiDi presentation and reconciliation work still needs to reach the same consumed adapter after exact `PresentationMutationAuthority` validation; retaining a second adapter or generic raw callback would recreate the capability-substitution defect. Fourth, uncertain lifecycle outcomes must preserve every exact non-authorizing owned handle needed for recovery, including siblings invalidated indirectly by another context's failure, and a failed `finish()` must not discard the same bound adapter needed to repair the rejected completion. -Lifecycle failures also need lossless evidence. A BiDi adapter can successfully create a user context before later browsing-context creation or verification becomes uncertain. Duplicate adapter output can expose an offending handle that must not be silently discarded or automatically destroyed. Destruction can fail without proving that the exact isolation boundary is gone. These outcomes require recovery quarantine while retaining every exact browser-issued identity that is already known. +Lifecycle failures require lossless evidence while the aggregate remains available. A BiDi adapter can successfully create a user context before later browsing-context creation or verification becomes uncertain. Duplicate adapter output can expose an offending handle that must not be silently discarded or automatically destroyed. Two lifecycle facts can have identical remote handle values while belonging to different create attempts; raw-handle equality must not collapse an accepted owner and a later rejected/unsettled candidate. Destruction can fail without proving that the exact isolation boundary is gone. A failure on one owned context can force all other active siblings into uncertainty, so those sibling handles also have to remain enumerable. Transport liveness remains orthogonal to ownership certainty. -Transport liveness is independent from ownership certainty. A session already in `RecoveryRequired` can subsequently lose its transport; that new fact must be recorded without erasing the recovery evidence. Conversely, merely entering recovery does not prove the transport is dead. - -The 9 September 2026 WebDriver BiDi Working Draft defines user-context identifiers and the `browser.createUserContext`, `browsingContext.create`, and `browser.removeUserContext` lifecycle. Those commands remain adapter capabilities rather than OriginWeave policy authority, and command ACK alone is not destruction proof. +WebDriver BiDi publication freshness is owned by the canonical `originweave-bidi` receipt at `docs/traceability/webdriver-bidi-publication-current.md`; Browser Session does not restate dated Working Draft currentness. Runtime compatibility remains independently qualified and publication churn does not repin runtime behavior. The canonical WebDriver BiDi contract defines `browser.UserContext` as `text` and defines `browser.createUserContext`, `browsingContext.create`, and `browser.removeUserContext`; it does not define a 4096-byte identifier limit. These identifiers and commands remain adapter capabilities/addressability rather than OriginWeave policy authority, and command ACK alone is not destruction proof. ## Decision drivers -- Raw WebDriver/BiDi identifiers are addressability, not mutation or cleanup authority. -- Sequential aggregate recreation must not make a retained stale authority valid again. -- The lifecycle adapter must receive the same non-reused session incarnation used by authority validation; an aggregate-only nonce is insufficient. -- Known remote identities from partial creation, duplicate output, or unproven destruction must be retained as recovery evidence without becoming command authority. -- Ownership recovery and transport liveness must remain orthogonal. -- Duplicate or uncertain outcomes fail closed and must not permit false normal completion. -- Destruction I/O must use the exact stored handle and session incarnation rather than reconstructing authority from raw identifiers. +- Raw WebDriver/BiDi identifiers and adapter-chosen values are addressability, not mutation or cleanup authority. +- Browser-issued protocol identity needed for exact lifecycle ownership and recovery must remain losslessly representable; an uncited implementation constant must not silently redefine `browser.UserContext` semantics. +- No arbitrary adapter callback may be required to establish lifecycle-port ownership. +- A caller must not be able to substitute or recover the concrete adapter after Browser Session binding. +- Browser Session create/destroy capabilities remain non-caller-constructible. +- Every remote create attempt and every uncertain/rejected result must be correlated to one exact Browser Session-issued attempt. +- Browser Session, not the adapter, decides whether a returned domain handle is accepted or rejected. +- Equal remote handle values do not imply equal lifecycle facts; owner evidence and later candidate evidence require separate provenance. +- Protocol-specific pending/accepted/quarantined tuples remain the WebDriver BiDi ACL owner's truth. +- Presentation/reconciliation I/O must use the exact consumed adapter only after current aggregate authority validation. +- Diagnostic formatting must not invoke adapter-owned `Debug` or expose adapter-internal state. +- Silent loss of active/uncertain ownership on ordinary `BoundBrowserSession` drop must be observable without performing browser I/O from `Drop`. +- A rejected `finish()` must retain the exact bound lifecycle owner so cleanup/reconciliation and a later retry remain possible. +- Sequential aggregate recreation must not make retained stale authority valid again. +- Recovery evidence and transport liveness remain orthogonal. - Browser Session remains the domain authority; WebDriver BiDi, CDP, MCP, and LLMs remain adapters or consumers. ## Decision -Introduce `originweave-browser-session` as an independent Rust bounded context and retain ADR status `Proposed` until protected-main and real-browser acceptance exist. - -1. `BrowserSession` is the aggregate root. `BrowserSession::start` allocates a process-local, monotonically non-reused `BrowserSessionIncarnation` before browser I/O. Allocation fails closed before `u64` wrap. -2. Presentation authority is intentionally non-serializable. A process restart destroys every outstanding in-memory authority. Within one process, `BrowserSessionIncarnation` prevents sequential ABA when a later aggregate reuses the same external session, isolation, context, and local epoch values. -3. The same `BrowserSessionIncarnation` is passed through `DisposableContextPort` create and destroy calls. Adapters must scope their remote ownership mapping to that incarnation. Ignoring it violates the port contract. -4. A context enters the owned set only after `DisposableContextPort::create_disposable_context` returns a `DisposableContextHandle`. Raw `BrowsingContextId` input never creates ownership. -5. `PresentationMutationAuthority` is opaque and binds browser session, Browser Session incarnation, disposable isolation, browsing context, and context epoch. All fields must match current aggregate ownership before adapter I/O. -6. `DisposableContextCreateError::CreateFailedClean` is valid only when no remote boundary exists. `DisposableContextCreateError::CreateFailedUncertain(Option)` enters `RecoveryRequired`; when the browser-issued isolation/user-context identity is known, it is preserved exactly. -7. Duplicate browsing-context or isolation output enters `RecoveryRequired` and stores the complete offending `DisposableContextHandle` as recovery evidence. OriginWeave does not auto-destroy it because the adapter may have returned foreign state. -8. `BrowserSessionRecoveryEvidence` records only reconciliation evidence: `PartialCreationIsolation`, `DuplicateAdapterHandle`, and `UnprovenDestruction`. It grants no browser command authority. -9. Destruction validates exact authority before I/O, passes the current incarnation and stored handle to the port, and succeeds only after the adapter proves the exact boundary is gone. `DisposableContextDestroyError` moves the record and aggregate into recovery and retains the exact failed handle. -10. Transport liveness is stored separately from ownership state. The first `record_transport_loss()` records the fact even after `RecoveryRequired`; later duplicate reports are idempotent. If transport is lost while the aggregate is `Active`, the lifecycle state becomes `TransportLost` and active contexts become uncertain. If ownership was already uncertain, `RecoveryRequired` remains the lifecycle state and the transport-loss fact is retained alongside it. -11. `RecoveryRequired`, `TransportLost`, and `Ended` reject active-only creation, authority issuance/advance, destruction, and normal end. Reconciliation is a later, separately authorized design. -12. Context epochs remain monotonic authority identities within one aggregate. They invalidate older authority after navigation or another lifecycle boundary but are not a substitute for session incarnation. +Introduce and retain `originweave-browser-session` as an independent Rust bounded context. ADR status remains `Proposed` until protected-main and real-browser acceptance exist. + +1. `BrowserSession::start` allocates a process-local, monotonically non-reused `BrowserSessionIncarnation` before browser I/O. Exhaustion fails closed. +2. Presentation authority is intentionally non-serializable. `BrowserSessionIncarnation` prevents sequential ABA within one process. +3. Browser Session uses a **linear lifecycle-port binding**. `BrowserSession::bind_lifecycle_port` consumes both aggregate and one concrete `DisposableContextPort` into `BoundBrowserSession

` without invoking adapter code. +4. `BoundBrowserSession

` has **no public raw port accessor** and no lifecycle method that accepts an alternate port. Tests observe adapter behavior through independently retained inert counters/ledgers rather than extracting `&P`. +5. `DisposableContextPort` has no identity-preflight method. Adapter identity is structural composition, not a self-asserted scalar. +6. `DisposableContextCreateRequest` remains opaque and carries the already-reserved `BrowserContextEpoch` as an exact per-create transaction identity. The `(session, incarnation, attempt epoch)` tuple is unique for create attempts in one live aggregate and is not caller-constructible as a request. +7. After `create_disposable_context` returns a handle, Browser Session validates isolation and browsing-context ownership before granting authority. +8. Browser Session privately issues `DisposableContextCreateCompletion` for that exact attempt with `Accepted` or `Rejected`. +9. The adapter must keep a successful remote create result non-authorizing until the matching `Accepted` completion. `Rejected` results remain non-authorizing recovery/quarantine state. A completion that cannot be proven for the exact pending attempt fails closed and sends the aggregate to `RecoveryRequired`. +10. `DisposableContextCreateRecoveryEvidence` preserves create-transaction identity independently from remote-handle reconciliation: `FailedUncertain` retains the exact aggregate-issued attempt epoch plus optional known isolation; `DuplicateCandidate` retains exact attempt epoch plus complete candidate handle; `CompletionUnsettled` retains exact attempt epoch, `Accepted|Rejected` disposition, and complete candidate handle. These records grant no browser command authority. +11. Candidate-oriented `DuplicateAdapterHandle` and `UnsettledAdapterHandle` do not count as owner-oriented recovery evidence solely because their handle values equal a previously accepted owner. `RecoveryRequiredOwnedHandle` remains a separate lifecycle fact for that owner. +12. Protocol-specific remote tuple contents are not copied into Browser Session. #314/#316 owns WebDriver BiDi pending/accepted/quarantined storage and remote-liveness validation. +13. `DisposableIsolationId` preserves the browser-issued isolation identity exactly for create/destroy/recovery addressability. It must not truncate, normalize, hash, or reject an otherwise protocol-valid `browser.UserContext` solely because of an arbitrary local identifier-length constant. Resource-exhaustion limits, where required, belong at a cited protocol/frame/runtime boundary or an explicit deployment policy that still preserves lossless recovery evidence. +14. `DisposableContextDestroyRequest` remains opaque and is created only after exact presentation-authority validation. It carries Browser Session addressability, incarnation, the exact stored handle, and the exact validated context epoch for provenance/correlation. +15. `PresentationMutationAuthority` binds browser session, Browser Session incarnation, disposable isolation, browsing context, and context epoch. All fields must match current aggregate ownership before adapter I/O. +16. `DisposableContextCreateError::CreateFailedClean` is valid only when no remote boundary exists. `CreateFailedUncertain(Option)` enters `RecoveryRequired` and retains the exact create attempt even if the optional isolation is absent; any known isolation identity is preserved exactly. +17. Duplicate browsing-context or isolation output enters `RecoveryRequired`, stores the complete offending `DisposableContextHandle`, stores the exact rejected create-attempt fact, and sends a `Rejected` completion for that exact attempt. OriginWeave does not auto-destroy ambiguous output. +18. `BrowserSessionRecoveryEvidence` includes partial-creation identity, duplicate handle, unsettled complete adapter handle, exact unproven-destruction handle plus validated epoch, `RecoveryRequiredOwnedHandle` for every still-active sibling made uncertain by a recovery transition, and `TransportLossOwnedHandle` for each active handle whose remote liveness becomes uncertain on transport loss. These values are explicit **unproven destruction** evidence rather than cleanup proof and grant no browser command authority. Repeated recovery/loss observation must not duplicate the same owner-specific evidence. +19. Destruction validates exact authority before I/O. `DisposableContextDestroyError::DestroyFailed` means destruction was not proven; the owned record becomes uncertain, the exact failed handle and validated context epoch are retained as `UnprovenDestruction`, and the aggregate enters recovery rather than treating command acknowledgement or bookkeeping as cleanup proof. Any other active sibling is projected as `RecoveryRequiredOwnedHandle` before it becomes uncertain. +20. Transport liveness is stored separately from ownership state. The first `record_transport_loss()` records exact previously active handles as non-authorizing recovery evidence, marks them uncertain, and records the transport fact. If ownership is already `RecoveryRequired`, the stronger lifecycle state is preserved. +21. `RecoveryRequired`, `TransportLost`, and `Ended` reject normal active-only lifecycle and authority operations. +22. `AuthorizedContextOperationRequest` is non-caller-constructible. `AuthorizedContextOperationPort` lets a dependent adapter define a narrow operation vocabulary while Browser Session first validates current `PresentationMutationAuthority`, binds the exact stored handle and validated epoch, and routes the request through the same consumed adapter instance. `AuthorizedContextOperationError::BrowserSession` is returned before adapter I/O for stale/foreign authority; adapter execution errors remain separately typed. Browser Session does not own WebDriver BiDi command semantics. +23. `BoundBrowserSession

` implements a manual redacted `Debug` projection over inert Browser Session fields only. Formatting never calls `P::fmt` and never renders adapter-internal state. +24. `BoundBrowserSession

` is `#[must_use]`. `finish(&mut self)` admits normal completion only after all owned contexts have proven destruction. A failed `finish()` returns the domain error without consuming or dropping the wrapper, so the same exact bound adapter and ownership ledger remain available for cleanup/reconciliation and retry. After success the aggregate is `Ended`, and later wrapper destruction is inert. `Drop` never performs browser I/O; if unresolved remote ownership remains, it increments the process-local `abandoned_bound_session_count()` operability signal. +25. The abandonment counter is deliberately not destruction proof and is not durable cross-process recovery storage. Exact recovery facts must be persisted by the separately authorized recovery owner before process termination. Until that owner path is integrated, crash/process-restart reconciliation remains an explicit buyer-acceptance gap rather than an implicit guarantee. +26. Context epochs remain monotonic authority identities within one aggregate and also provide the create-attempt correlation allocated before remote create I/O. +27. Process-local atomic counter updates use `AtomicU64::try_update` with the predecessor memory orderings and closures. This is a deprecation root fix, not a gate suppression or semantics change. ## Alternatives considered -### Treat any known context as owned +### Adapter-supplied identity or preflight callback -Rejected. It restores the authority-confusion defect and allows one task to clear another task's state. +Rejected. A scalar can be replayed and a shared-reference callback can still have side effects before Browser Session authority exists. -### Depend only on browser-issued isolation identity +### Public read-only `&P` after binding -Rejected. The WebDriver BiDi user-context identifier is suitable lifecycle addressability, but this ADR does not assume a historical non-reuse guarantee after removal. A later aggregate therefore needs a separate OriginWeave lifecycle generation. +Rejected. Rust `&P` forbids an ordinary mutable borrow but does not prohibit interior mutation or remote effects from `&self` methods. The concrete adapter would remain a capability escape. -### Add an aggregate-only random or monotonic nonce +### `#[derive(Debug)]` over `BoundBrowserSession

` -Rejected if it does not reach the lifecycle adapter. It would stop one aggregate from accepting another aggregate's token while still allowing a valid current token to address a remote boundary through aliasable adapter keys. The selected `BrowserSessionIncarnation` participates in both authority validation and port calls. +Rejected. Derived formatting delegates to `P::fmt`; a side-effecting or secret-bearing adapter `Debug` becomes an authority/data-exposure escape. Manual redacted formatting is selected. -### Persist authority generations globally +### Generic `FnOnce(&mut P)` callback -Deferred and unnecessary for the current in-process authority model. Presentation authority is not durable across process restart; recovery across restart belongs to evidence/reconciliation design, not silent authority resurrection. +Rejected. Although it would reach the exact consumed adapter, it hands unrestricted adapter authority back to callers and defeats the anti-corruption boundary. A typed `AuthorizedContextOperationPort` operation is selected instead. -### Treat every uncertain lifecycle failure as transport loss +### Second retained adapter or shared client outside `BoundBrowserSession` -Rejected. Ownership uncertainty and transport liveness answer different operational questions. Collapsing them loses information needed for safe reconciliation. +Rejected. It recreates same-key/different-adapter target redirection and lets presentation/reconciliation work escape the exact lifecycle instance Browser Session accepted. -### Automatically clean duplicate or partial state +### Adapter-local create sequence number -Rejected. When ownership is ambiguous, cleanup itself can become a cross-owner destructive action. Exact recovery evidence is retained while normal authority stays blocked. +Rejected as authority. It may be useful internally, but Browser Session could not prove which pending remote tuple it was accepting. Correlation must originate in the aggregate-issued request. -### Snapshot and restore every predecessor presentation override +### Raw handle equality as recovery-fact identity -Deferred. OriginWeave does not yet have a complete queryable predecessor-state contract for every governed presentation surface. Disposable ownership remains the stronger first implementation. +Rejected. One accepted ownership fact and a later duplicate/unsettled candidate can carry exactly the same isolation/context values while representing different lifecycle transactions. Create-attempt evidence and owner evidence therefore remain separately typed. -## Consequences +### Reserved BrowserContextEpoch as create-attempt identity -The Browser Session aggregate now carries an explicit lifecycle generation through the anti-corruption boundary instead of treating protocol identifiers as durable capabilities. A retained token from aggregate A cannot validate against aggregate B solely because the browser or adapter later reused the same external identifiers and local epoch. +Selected. Browser Session already reserves the epoch before create I/O, it is non-caller-constructible, monotonic within the aggregate, and the same value becomes the accepted context's first mutation epoch. -Recovery is also diagnosable rather than merely terminal. Known partial user-context identities, duplicate returned handles, and exact handles whose destruction could not be proven remain available as `BrowserSessionRecoveryEvidence`. This evidence is purpose-bound to later reconciliation; it is not a cleanup credential. +### Hard-coded maximum length for `browser.UserContext` -Transport failure can now be observed after ownership has already become uncertain without replacing or erasing that uncertainty. This supports later recovery planning that distinguishes “ownership uncertain but transport still live” from “ownership uncertain and transport lost.” +Rejected unless an authoritative protocol/runtime bound is cited and versioned. The current WebDriver BiDi WD defines `browser.UserContext` as `text` and does not define a 4096-byte identifier ceiling. OriginWeave therefore must not convert a browser-issued, otherwise valid identity into ownership loss because of an implementation-chosen domain constant. -The selected process-local incarnation has a deliberate scope. It prevents ABA only for outstanding in-memory authority within the running process. Durable restart reconciliation must use separately persisted evidence and browser observation; this ADR does not serialize or resurrect authority across restart. +### Consuming `finish(self)` before validation -## Security and governance impact +Rejected. An expected `ActiveContextRemains` would destroy the only wrapper that owns the accepted adapter and private lifecycle ledger. Validation therefore occurs through `finish(&mut self)`; only successful completion changes the aggregate to `Ended`. -No page-controlled value, raw browser-session id, raw browsing-context id, user-context string, provider/model decision, or LLM output can mint presentation authority. The adapter receives domain-issued incarnation information only as a lifecycle-scoping input and cannot manufacture Browser Session policy authority. +### Browser I/O from `Drop` -Unknown or duplicate remote state is quarantined rather than destroyed speculatively. This reduces the risk that recovery logic removes another owner's user context. It does not replace Chromium sandboxing, egress policy, Keyverse secret handling, Wardnet controls, or central workflow security. +Rejected. Rust destruction is synchronous and cannot prove remote cleanup. `Drop` is restricted to non-I/O abandonment observability; normal completion is explicit through proven destruction plus `finish()`. -## Tests and exact evidence +### Automatically clean duplicate or rejected state + +Rejected. Ambiguous ownership makes speculative cleanup a potential cross-owner destructive action. + +## Consequences + +The active stack receives a breaking trait extension for presentation/reconciliation adapters: implementations that need post-create authorized operations implement `AuthorizedContextOperationPort` and keep their protocol-specific command vocabulary in the adapter. #316 must restack non-force and map this boundary into its pending/accepted/quarantined BiDi state. + +The bound adapter is not publicly recoverable from `BoundBrowserSession`. Application and test code that needs observability retains inert metrics or diagnostic projections separately. Manual `Debug` exposes only Browser Session domain summary fields and a redacted port marker. -The test suite covers raw-context rejection, bounded isolation identity parsing, typed clean/uncertain creation, retained partial identity, duplicate-handle evidence, epoch exhaustion, stale epoch rejection, foreign-session/isolation rejection, destruction failure, transport loss, normal end, and incarnation-allocation exhaustion. +Entering `RecoveryRequired` now preserves exact handles for active siblings before marking them uncertain. Create-candidate facts and active-owner facts remain distinct even when they contain the same external handle values, so recovery can enumerate every potentially live boundary without reconstructing command authority from identifiers. -A dedicated hostile test, `stale_authority_cannot_cross_sequential_session_incarnations`, creates aggregate A, destroys and ends it, creates aggregate B with the same external session/user-context/browsing-context values and local epoch, and requires A's retained authority to fail before B adapter I/O while B's current authority succeeds. The port records incarnation values so the test also proves that the lifecycle mapping receives the new generation. +Create uncertainty and completion-settlement failure now retain exact aggregate-issued create-attempt provenance independently from handle-level reconciliation evidence. Transport loss preserves exact previously active handles as non-authorizing recovery evidence. Completion or destruction failure remains ownership uncertainty and does not mint normal authority. -`destroy_failure_requires_recovery_before_any_new_authority` requires an unproven destruction to retain the exact failed handle, enter `RecoveryRequired`, then record a later real transport loss without erasing ownership evidence; repeated loss reports are idempotent. +A failed `finish()` leaves the same `BoundBrowserSession` usable for cleanup/reconciliation and retry. Ordinary unresolved wrapper abandonment is process-locally observable, but exact crash/restart recovery still requires a canonical persistence/handoff path. This ADR does not claim that the in-memory counter is durable recovery. -The RED for the sequential ABA defect was captured on exact `ec145963ad8fe19c9416f2b3856b94660082dbf7` in CI `34469580144`: repository contracts and formatting passed, and Rust `Run tests` failed at the new hostile test before Clippy/rustdoc. The production fix and subsequent documentation/test updates must earn a new exact-head GREEN; predecessor evidence does not transfer. +## Security and governance impact + +No page-controlled value, raw browser identifier, adapter-selected scalar, diagnostic reference, provider/model decision, or LLM output can mint lifecycle completion or presentation authority. Remote creation stays non-authorizing until the aggregate validates ownership and accepts that exact attempt. Post-create adapter I/O is admitted only through current aggregate authority and the exact consumed adapter instance. + +Lossless retention of browser-issued user-context identity and create-attempt provenance is an ownership requirement, not authority delegation. Resource controls must not create an untracked remote isolation boundary by discarding or rewriting the only exact addressability or transaction identity needed for recovery. -Repository contracts, canonical formatting, locked Rust tests, strict Clippy, rustdoc/API docs, exact function/line/region/branch coverage, independent review, and applicable central checks remain required before ordinary adoption into #313. +This decision does not replace Chromium sandboxing, EgressWeave, Keyverse, Wardnet, or central workflow security. + +## Tests and exact evidence + +Required executable cases include: + +- binding invokes no arbitrary adapter callback before an aggregate-issued create request; +- the concrete bound adapter cannot be recovered through a public `lifecycle_port()` accessor; +- a second adapter cannot be substituted for create or destroy after binding; +- two successful remote create candidates in the same session incarnation receive distinct attempt epochs; +- `CreateFailedUncertain(Some/None)` retains the exact aggregate-issued attempt epoch even without a complete handle; +- one candidate can be accepted and the other rejected without pending-state collision or overwrite; +- accepted-completion failure and rejected-completion failure both fail closed and preserve exact attempt epoch, disposition, and complete candidate handle; +- an accepted owner and a later rejected/unsettled candidate with identical remote handle values remain separate lifecycle facts (`create_recovery_same_handle_distinct_fact.rs`); +- an otherwise-valid browser-issued `browser.UserContext` longer than the former 4096-byte implementation threshold remains losslessly representable for exact destroy/recovery rather than being rejected by an arbitrary domain cap; +- `DisposableContextDestroyError::DestroyFailed` preserves the exact failed handle and validated epoch, enters `RecoveryRequired`, and never counts a destroy command acknowledgement as proof; +- `RecoveryRequired` preserves each indirectly invalidated active sibling exactly once as `RecoveryRequiredOwnedHandle` while retaining the triggering context's cause-specific evidence; +- transport loss preserves every previously active exact handle as `TransportLossOwnedHandle` without adapter I/O or authority resurrection; +- formatting a bound session does not invoke adapter-owned `Debug` and does not expose adapter-internal state; +- an authorized operation reaches the exact consumed adapter, adapter errors remain typed, and stale authority fails before adapter I/O; +- dropping a bound session with unresolved ownership performs no implicit browser cleanup and increments the abandonment operability signal; +- a failed `finish()` performs no browser I/O or abandonment, retains the same bound owner, permits exact cleanup, and succeeds on retry after proven destruction; +- recovery, sequential-incarnation ABA, epoch exhaustion, foreign authority, destruction failure, transport loss, and normal end remain covered. + +The historical exact `9cde981899950b900698a17e7fa739af59f6bb4f` CI `34531025582` passed exact production coverage but failed canonical Rust formatting. The historical `729603ae4feadd369eee7819a45d6850604975da` run `34541860394` passed exact production coverage but failed the repository contract because ADR 0114 had lost the `DisposableContextDestroyError` trace. Exact `d5046e76cb7555b448b728ea1bed9ba1ea8de8c3` / CI `34573175780` passed Python repository contracts but failed canonical formatting; production coverage stopped during measurement because the two intentionally RED hostile lifecycle cases were still unresolved. Historical GREEN never transfers. Successor evidence must be fresh: repository contracts, canonical formatting, locked tests, strict Clippy, rustdoc/API docs, and production function/line/region/branch coverage each exactly 100%. ## Buyer acceptance still open -This slice does not yet prove real WebDriver BiDi `browser.createUserContext`/`browsingContext.create`/`browser.removeUserContext` integration, browser-observed destruction, recovery reconciliation, Browser Session→BiDi private-witness conversion, pinned Chromium presentation post-conditions, crash/restart cleanup, #299 3/3 Agent Task replay, or protected-main release/SBOM/provenance/reproducibility/rollback. +This slice does not yet prove actual WebDriver BiDi lifecycle integration, browser-observed destruction, protocol-specific pending/accepted/quarantined binding, durable crash/process-restart recovery handoff, Browser Session authority conversion into BiDi presentation private witnesses, current Chromium post-condition observation, #299 3/3 browser trials, or protected-main release/SBOM/provenance/reproducibility/rollback. ## Migration and rollback -The change remains additive on the active stacked branch. Consumers must adopt the new `BrowserSession::start` result and incarnation-aware `DisposableContextPort` contract. Until a reviewed adapter bridge exists, presentation mutation remains fail closed behind private ownership witnesses. Rollback removes this active-PR bounded-context slice without weakening protected Chromium or central security policy. +Consumers continue to bind once with `BrowserSession::bind_lifecycle_port(port)` and perform lifecycle work through `BoundBrowserSession`. Code must not depend on recovering `&P`. Adapter implementations add exact-attempt staging/completion and, when they need post-create presentation or reconciliation I/O, implement the typed `AuthorizedContextOperationPort` operation vocabulary. + +Normal owners destroy every owned context, call `finish()`, and may then release the ended wrapper. If `finish()` rejects, they retain the same wrapper, perform permitted cleanup/reconciliation, and retry. Recovery owners must persist exact recovery evidence before terminating a process that still has unresolved ownership; the abandonment counter is an operability alert, not a persistence mechanism. + +Rollback may return to the predecessor active-PR API only if these authority findings are disproved with stronger executable evidence. It must not restore a raw adapter accessor, derived adapter `Debug`, self-reported identity, unrestricted adapter callback, consuming failed-finish path, arbitrary browser-user-context length cap, raw-handle recovery deduplication, or adapter-local call order as an authorization boundary. ## Open follow-ups -- Implement the WebDriver BiDi disposable-user-context adapter with incarnation-scoped mapping and observed destruction post-condition. -- Define the Browser Session→BiDi ACL without exposing public ownership constructors. -- Design separately authorized reconciliation for `BrowserSessionRecoveryEvidence`, including browser/process restart. +- Complete acceptance and protected-main integration of ADR 0116's purpose-bounded same-adapter recovery custody for `RecoveryRequired` / `TransportLost`; durable recovery persistence/reconciliation remains a separate follow-up. +- Bound hot ownership state independently from durable/audit history so proven destruction does not create unbounded validation cost. +- Restack #316 onto the verified Browser Session successor and implement WebDriver BiDi pending → accepted/quarantined transaction settlement plus typed presentation/reconciliation operations. +- Define the separately authorized durable recovery persistence/reconciliation owner for Browser Session recovery evidence and unresolved abandonment. - Replay #299 historical pinned Chromium evidence after the canonical sandbox/runtime repair, then run a separate current-Stable qualification. -- Revisit predecessor capture/restore only if reusable attached contexts become a buyer requirement. ## Supersession / reversal conditions -Supersede this ADR if the browser platform provides a complete, queryable, generation-safe ownership primitive with exact destruction evidence, or if OriginWeave adopts another isolation primitive with equivalent guarantees. Do not regress to raw context identity as authority. +Supersede this ADR if the browser platform provides a complete, queryable, generation-safe ownership primitive with exact destruction evidence, or if OriginWeave adopts another isolation primitive with equivalent guarantees. Do not regress to raw context identity or adapter-selected identity as authority. ## References -Browser Testing and Tools Working Group. (2026, September 9). *WebDriver BiDi* (W3C Working Draft). World Wide Web Consortium. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/ +Browser Testing and Tools Working Group. (2026). *WebDriver BiDi*. World Wide Web Consortium. https://www.w3.org/TR/webdriver-bidi/ + +OriginWeave. (2026). *WebDriver BiDi publication-current receipt*. `docs/traceability/webdriver-bidi-publication-current.md` diff --git a/docs/adr/0116-browser-session-recovery-custody-and-hot-ownership.md b/docs/adr/0116-browser-session-recovery-custody-and-hot-ownership.md new file mode 100644 index 000000000..b1b486607 --- /dev/null +++ b/docs/adr/0116-browser-session-recovery-custody-and-hot-ownership.md @@ -0,0 +1,201 @@ +# ADR 0116: Browser Session recovery custody and bounded hot ownership + +- Status: Proposed +- Date: 2026-09-15 +- Last amended: 2026-09-16 +- Extends: ADR 0114 +- Owning bounded context: `originweave-browser-session` + +## Context + +ADR 0114 establishes Browser Session as the owner of disposable-context lifecycle authority. One concrete lifecycle adapter is consumed into `BoundBrowserSession

`; raw protocol identifiers never mint create, presentation, navigation, cleanup, or recovery authority. + +Recovery introduces three additional constraints. + +First, unresolved ownership must retain the exact adapter instance that observed the remote state. Reconstructing a second adapter from identifiers breaks lifecycle custody, while exposing raw `P` or an unrestricted callback creates an authority escape. + +Second, recovery command execution and recovery completion are different operations. A browser or driver command ACK is not proof that remote ownership has been reconciled. Browser Session therefore needs a proof-bearing settlement transition that retires exactly one current recovery fact only after independently qualified evidence has been verified by the retained adapter. + +Third, even while recovery remains open, a generic recovery command must not receive authority or evidence broader than the fact that justifies that command. Passing the full recovery ledgers to every adapter operation discloses unrelated sibling recovery facts and lets an operation run without presenting the Browser Session-issued fact it is meant to reconcile. Recovery operations therefore require one opaque current `RecoveryFact`, are validated before adapter I/O, and receive only the selected fact. + +WebDriver BiDi pending/accepted/quarantined tuple truth, protocol event correlation, replay qualification, remote-liveness interpretation, and concrete proof semantics remain #316 responsibilities. Durable cross-process persistence also remains outside the in-memory Browser Session aggregate. + +## Decision drivers + +- Preserve the exact consumed adapter across unresolved ownership without making it ambient. +- Require one current Browser Session-issued `RecoveryFact` for each generic recovery operation. +- Reject foreign, stale, replayed, shifted, or out-of-range operation facts before adapter I/O. +- Expose only the selected recovery fact to the adapter command path; do not disclose sibling ledgers. +- Keep adapter command success/failure separate from independent recovery proof. +- Revoke generic recovery I/O after final proof-bearing settlement reaches `Ended`. +- Do not mint recovery custody from ownership-clean `TransportLost`. +- Bind settlement to one opaque current fact, not a raw vector index or protocol identifier. +- Retire only the exact independently verified fact; preserve sibling uncertainty. +- Keep identity-oriented and create-attempt recovery facts independently addressable. +- Never restore ordinary create, navigation, presentation, cleanup, or generic recovery-command authority after reconciliation. +- Preserve failed-destroy ownership and epoch evidence until independently qualified reconciliation proves it gone. +- Keep command-authority hot state bounded to live or uncertain ownership. + +## Authority boundaries + +Browser Session owns lifecycle identity, ownership state, context epochs, ordinary lifecycle/presentation admission, navigation-generation custody, one-way transition into recovery custody, opaque `RecoveryFact` issuance, current-fact validation, deterministic exact-fact retirement, recovery-revision advancement, and terminal revocation of recovery-command authority. + +`BoundBrowserSessionRecovery

` owns the same concrete adapter instance but is not a protocol-specific recovery engine. It exposes read-only recovery evidence and opaque fact issuance. It does not expose raw `P`, the inner `BoundBrowserSession`, ordinary Browser Session methods, or a caller-provided callback over the adapter. + +When `P: RecoveryContextOperationPort`, `execute_recovery_context_operation(fact, operation)` is available only while the aggregate remains `RecoveryRequired` or evidence-bearing `TransportLost`. Before adapter I/O Browser Session validates: + +1. the fact belongs to the exact Browser Session id and process-local incarnation; +2. the fact was issued at the current recovery revision; +3. the selected ledger/index still addresses a current recovery fact. + +`RecoveryContextOperationRequest` is then privately constructed with Browser Session id, incarnation, unresolved state, the **one selected** identity-oriented or create-attempt fact, and the adapter-defined operation. It does not contain full recovery vectors. Foreign facts return `RecoveryContextOperationError::AuthorityMismatch`; stale or no-longer-current facts return `RecoveryContextOperationError::StaleFact`; both fail before adapter I/O. Adapter success or `RecoveryContextOperationError::Adapter(E)` leaves Browser Session evidence unchanged. Reusing the same current fact for retries is allowed until a successful settlement advances the revision. + +When `P: RecoverySettlementPort`, `settle_recovery_fact(fact, proof)` applies the same exact-fact identity/revision/address validation. The retained adapter verifies independently qualified proof carried by `RecoverySettlementRequest

`. Browser Session, not the adapter, commits retirement of the selected fact and then advances the monotonic recovery revision. Every previously issued `RecoveryFact`, including unrelated sibling handles, becomes stale after that mutation. + +Once both recovery ledgers and uncertain hot ownership are empty, the aggregate becomes terminal `Ended`. `execute_recovery_context_operation` then returns `RecoveryContextOperationError::RecoveryClosed` before adapter I/O. Complete recovery never recreates `Active` or ordinary browser authority. + +The only bridge receiving `&mut P` remains crate-private `BoundBrowserSession::dispatch_recovery_operation`. `RecoveryContextOperationRequest`, `RecoveryFact`, and `RecoverySettlementRequest` have no public construction path. + +#316 owns WebDriver BiDi proof qualification. A `browsingContext.contextDestroyed` event, session-loss observation, liveness conclusion, or tuple transition is not automatically proof merely because it came from the protocol. #316 decides which observations can satisfy `RecoverySettlementPort::Proof`; Browser Session consumes only its already-qualified proof under the deterministic exact-fact contract. + +## Options considered + +### Return raw `P` or expose an unrestricted callback + +Rejected. Either form recreates ambient adapter capability outside Browser Session authority. + +### Clone or reconstruct the adapter for recovery + +Rejected. Equal endpoint, credentials, or identifiers do not prove same lifecycle instance or pending protocol state. + +### Treat any `TransportLost` as recovery authority + +Rejected. Transport loss proves liveness loss, not unresolved remote ownership. Ownership-clean transport loss cannot mint a recovery command path. + +### Execute a recovery operation without a `RecoveryFact` + +Rejected. State-level recovery custody is too broad to authorize an arbitrary operation. It allows the caller to reach the retained adapter without identifying the exact unresolved fact that justifies the command. + +### Pass both full recovery ledgers to every recovery operation + +Rejected. It violates purpose limitation and least authority by disclosing sibling recovery facts unrelated to the selected command. The operation request carries exactly one selected fact. + +### Treat successful command execution as reconciliation proof + +Rejected. Command completion is not a browser-observed post-condition. Recovery commands never mutate Browser Session uncertainty directly. + +### Keep generic recovery I/O callable after complete settlement + +Rejected. Once unresolved facts are gone, the purpose that justified retained-adapter access is gone. Terminal `Ended` closes that route before another adapter call. + +### Let the adapter delete recovery evidence directly + +Rejected. Protocol data must not rewrite Browser Session ownership truth. + +### Identify a recovery fact by raw vector index + +Rejected. Retiring one fact shifts indices. `RecoveryFact` carries a monotonic revision; successful settlement invalidates all prior handles. + +### Keep previously issued sibling facts valid after another fact settles + +Rejected. That would make index-shift replay ambiguous. Callers must reread current custody after mutation. + +### Clear both recovery ledgers when one condition is proven + +Rejected. Identity/ownership uncertainty and create-attempt transaction uncertainty are independent facts and retire independently. + +### Restore an ordinary `BoundBrowserSession

` after reconciliation + +Rejected. Recovery is a one-way boundary. Complete reconciliation reaches `Ended`, never `Active`. + +### Keep every proven-destroyed context as a permanent hot tombstone + +Rejected. Authorization state and durable audit history have different retention requirements. Proven ordinary destruction removes hot ownership while monotonic epochs reject stale authority. + +## Decision + +1. `BoundBrowserSession::into_recovery(self)` is the only transition into recovery-only custody. +2. It succeeds from `RecoveryRequired`, or from `TransportLost` only while exact unresolved recovery/create-attempt evidence remains. +3. Handoff moves the existing `BoundBrowserSession

` and exact same adapter instance without browser I/O. +4. Recovery custody exposes lifecycle state, read-only evidence, and opaque current-revision `RecoveryFact` issuance; it exposes no raw adapter or ordinary Browser Session authority. +5. `RecoveryContextOperationPort` is the generic recovery-command boundary. Every call supplies a `RecoveryFact` plus adapter-defined operation. +6. Operation validation rejects foreign or stale facts before adapter I/O and sends only the selected current fact in `RecoveryContextOperationRequest`. +7. Operation success/failure does not settle, erase, or mutate Browser Session recovery state. +8. `RecoverySettlementPort` is the generic proof-verification boundary. Protocol-specific proof vocabulary remains adapter-owned. +9. `settle_recovery_fact` validates session/incarnation/revision/exact current fact before proof I/O, checks next-revision capacity, verifies proof through the exact retained adapter, then retires only the selected fact. +10. Verifier failure is non-mutating. Successful retirement advances the recovery revision and invalidates all previously issued handles. +11. Identity-oriented and create-attempt ledgers retire independently. +12. `UnprovenDestruction`, `RecoveryRequiredOwnedHandle`, and `TransportLossOwnedHandle` may retire the exact matching `Uncertain` hot ownership record. Candidate/partial-create evidence cannot consume a distinct accepted owner merely because remote values alias. +13. Partial settlement preserves every unrelated sibling fact and keeps recovery open. +14. When both ledgers and uncertain ownership are empty, Browser Session reaches terminal `Ended`; ordinary and generic recovery-command authority stay closed. +15. Proven ordinary destruction removes the live hot record. Failed destruction retains `Uncertain` ownership plus exact `UnprovenDestruction { context, context_epoch }` evidence. +16. Proven destruction may release raw browser identities for later reuse only under a new monotonic `BrowserContextEpoch`; predecessor authority cannot revive after ABA reuse. +17. `Drop` performs no browser I/O. Unresolved custody preserves process-local abandonment accounting only. +18. Browser-observed navigation remains a separate generation-qualified authority machine; recovery settlement cannot recreate navigation or presentation authority. +19. ADR 0116 remains `Proposed` until this complete slice reaches protected `main` with exact-head gates and independently observed real-browser recovery/destruction/navigation post-conditions. + +## Consequences + +Recovery now has two distinct least-authority paths on the same retained adapter: an exact-fact-scoped command path and an exact-fact proof-settlement path. The command path can be retried while its fact remains current but sees no sibling recovery evidence. Settlement changes the revision, forcing all pre-existing handles to be reacquired and preventing replay after index shifts. + +The revision is deliberately coarse: settling any fact invalidates every handle from the previous revision. Recovery fact counts are expected to be small, and correctness at this security boundary takes precedence over preserving stale handles. + +Hot ownership remains proportional to current live/uncertain state rather than historical throughput. Durable history and cross-process recovery require a separate authorized persistence owner. + +## Failure and degraded behavior + +A rejected `into_recovery` performs no I/O and returns the original bound owner. + +A recovery operation using a foreign or stale fact fails before adapter I/O. Adapter failure preserves custody and evidence. Adapter success also preserves custody and evidence; it is not proof. After final settlement reaches `Ended`, another operation returns `RecoveryClosed` before I/O. + +A settlement with a foreign, stale, replayed, or out-of-range fact fails before proof-verifier I/O. Proof rejection occurs after verifier I/O but before domain mutation. In both cases recovery ledgers remain unchanged. + +A failed destruction never retires hot ownership. Transport loss preserves active handles as non-authorizing evidence and cannot itself prove destruction. Transport loss with no unresolved browser state creates no recovery custody. + +If monotonic incarnation, context epoch, navigation generation, or recovery revision allocation exhausts, allocation fails closed rather than wrapping authority identity. + +## Security / privacy / governance impact + +Recovery custody is a capability-reduction boundary. Untrusted page data, model output, protocol identifiers, adapter-selected handles, recovery evidence, command acknowledgements, and model judgment cannot reconstruct deterministic Browser Session authority. + +Exact-fact command validation prevents a generic recovery operation from using custody alone as authority. Selected-fact-only requests also avoid disclosing unrelated recovery facts to the adapter, reducing purpose-unrelated propagation of browser identifiers or other recovery metadata. Session/incarnation/revision/exact-address validation occurs before adapter I/O, so foreign or stale handles cannot turn adapter command execution into an oracle or mutation channel. + +This ADR does not move EgressWeave, Wardnet, Keyverse, contextual-orchestrator, Chromium sandboxing, or WebDriver BiDi protocol truth into Browser Session. + +## Tests and acceptance evidence + +- `crates/originweave-browser-session/src/recovery.rs` + - one-way `into_recovery` + - `RecoveryContextOperationRequest` / `RecoveryContextOperationPort` + - `RecoveryFact` + - `RecoverySettlementRequest

` / `RecoverySettlementPort` + - pre-I/O exact-fact validation and terminal `RecoveryClosed` +- `crates/originweave-browser-session/tests/recovery_operation_exact_fact_scope.rs` + - selected-fact-only operation request + - stale fact after settlement rejected before adapter I/O + - foreign fact rejected before adapter I/O +- `crates/originweave-browser-session/tests/recovery_same_adapter_operation.rs` + - same-adapter command path for identity and create-attempt facts + - command success/failure is non-settling +- `crates/originweave-browser-session/tests/recovery_operation_terminal_closure.rs` + - final settlement closes generic recovery I/O before another adapter call +- `crates/originweave-browser-session/tests/recovery_exact_fact_settlement.rs` + - exact one-fact settlement, replay/sibling/foreign rejection, proof-failure non-mutation, independent ledgers, terminal closure +- `tests/test_browser_session_recovery_operation_contract.py` + - nonconstructible selected-fact request surface and hostile exact-fact fixtures +- `tests/test_browser_session_recovery_settlement_contract.py` + - opaque settlement API and validation order +- `docs/traceability/browser-session-lifecycle-authority.md` +- `docs/uml/browser-session-lifecycle-authority.md` + +These remain active-PR contracts until the exact head passes repository contracts, canonical rustfmt, locked tests, strict Clippy, rustdoc/API docs, production function/line/region/branch coverage at 100%, required review, and protected-main integration. + +## Migration and rollback + +Consumers of the prior active-PR recovery command signature must reacquire a current `RecoveryFact` and pass it to `execute_recovery_context_operation(fact, operation)`. Adapters must treat `RecoveryContextOperationRequest` as one selected fact, not a snapshot of both ledgers. + +If the selected-fact command boundary cannot be supported safely, rollback means removing the generic recovery-command surface and retaining read-only recovery custody plus proof-bearing settlement. Rollback must not restore raw adapter access, whole-ledger command requests, caller-constructible fact handles, or command-ACK settlement. + +## Follow-up + +#316 must consume this boundary without copying Browser Session source. Its next integration slice must bind WebDriver BiDi pending/accepted/quarantined tuple and remote-liveness evidence to the current `RecoveryFact`, qualify protocol-specific proof for `RecoverySettlementPort`, and prove with pinned Chromium that command ACK and browser-observed post-condition remain distinct. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2c492ba95..6820a423a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -67,10 +67,11 @@ ADR 0013, ADR 0014, ADR 0110, ADR 0111, and ADR 0112 exist only on this document | [0016](0016-bap-task-lifecycle-authority.md) | BAP task lifecycle and state authority | Proposed | BAP task states, transitions, recovery validation, transition sequencing, and authority separation | | [0113](0113-webdriver-bidi-screen-area-ownership.md) | WebDriver BiDi screen-area ownership witness | Proposed | Browser Session-owned screen-settings mutation, destructive reset boundary, and fail-closed adapter authority | | [0114](0114-browser-session-disposable-context-authority.md) | Browser Session disposable-context authority | Proposed | owned disposable context lifecycle, exact context epochs, presentation mutation authority, cleanup uncertainty and transport-loss invalidation | +| [0116](0116-browser-session-recovery-custody-and-hot-ownership.md) | Browser Session recovery custody and bounded hot ownership | Proposed | same-adapter one-way recovery custody, non-authorizing recovery evidence, proven-destroy hot-state retirement, and stale-authority ABA rejection | -ADR 0016 belongs to the active BAP lifecycle feature branch. ADR 0113 belongs to the active WebDriver BiDi screen-area ownership successor. ADR 0114 belongs to the Browser Session lifecycle successor for issue #312. Indexing them makes the branch documentation graph complete while preserving Proposed lifecycle and active-PR, non-protected-main maturity. +ADR 0016 belongs to the active BAP lifecycle feature branch. ADR 0113 belongs to the active WebDriver BiDi screen-area ownership successor. ADR 0114 and ADR 0116 belong to the Browser Session lifecycle successor for issue #312; ADR 0116 extends ADR 0114 without promoting either decision beyond Proposed. ADR 0115 is reserved by the active #316 WebDriver BiDi lifecycle ACL stack and must not be duplicated here. Indexing these decisions makes the branch documentation graph complete while preserving active-PR, non-protected-main maturity. -After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change ADR 0016, ADR 0113, or ADR 0114 from Proposed or assert implementation maturity. +After protected-main integration, retain this subsection only when it is intentionally serving as historical provenance; otherwise protected-main reconciliation must remove it. In either case, integration alone does not change ADR 0016, ADR 0113, ADR 0114, or ADR 0116 from Proposed or assert implementation maturity. Other active feature PRs may contain additional Proposed ADRs. Those files are not part of this canonical documentation line until integrated or deliberately reconciled here. Historical PR checks, stale branch state, or chat decisions never transfer ADR acceptance across a changed head. @@ -146,4 +147,4 @@ Material external standards or research belong in APA 7th format in [`../doctori - [`../traceability/README.md`](../traceability/README.md) maps requirements and decisions to implementation and evidence. - [`../DOCUMENTATION_FITNESS.md`](../DOCUMENTATION_FITNESS.md) records semantic completeness and stale/current findings across the graph. -If these artifacts disagree about current implementation, protected-main source, executable tests, built/released artifacts, configuration/migrations, and protected-main operational evidence appropriate to the claim define implementation truth. Accepted ADRs explain governing design decisions; they do not upgrade missing behavior into shipped behavior. The disagreement is a documentation or implementation defect that must be repaired rather than silently rationalized from conversation history. +If these artifacts disagree about current implementation, protected-main source, executable tests, built/released artifacts, configuration/migrations, and protected-main operational evidence appropriate to the claim define implementation truth. Accepted ADRs explain governing design decisions; they do not upgrade missing behavior into shipped behavior. The disagreement is a documentation or implementation defect that must be repaired rather than silently rationalized from conversation history. \ No newline at end of file diff --git a/docs/doctoring.md b/docs/doctoring.md index 44fb51d13..68517e3d4 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -6,7 +6,7 @@ This document records external evidence that changes OriginWeave architecture, t ### Browser automation and interoperability -The 3 September 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. OriginWeave pins this publication to the immutable dated TR `https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/`; the mutable `w3c.github.io/webdriver-bidi/` Editor's Draft is tracked separately and cannot silently redefine the adapter contract. Because the standard remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. +As of 17 September 2026, the canonical publication-current receipt at `docs/traceability/webdriver-bidi-publication-current.md` records the 16 September 2026 WebDriver BiDi Working Draft as the latest published version and the 14 September 2026 Working Draft as the previous published version. Execution compatibility is a separate claim and remains qualified against the immutable 3 September 2026 dated TR `https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/` until schema, semantics, conformance, and pinned-Chromium evidence admit another revision. The 9 September 2026 dated Working Draft remains historical/reference publication evidence, not the current publication or the runtime-qualified pin. The mutable `w3c.github.io/webdriver-bidi/` Editor's Draft is tracked separately and cannot silently redefine the adapter contract. Because the standard remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. The final Model Context Protocol `2026-07-28` specification defines the currently reviewed MCP generation. Its stateless request model carries protocol metadata per request and standard Streamable HTTP routing metadata for MCP operations; its Tools surface defines bounded, case-sensitive tool names and requires clients to treat tool annotations as untrusted unless supplied by a trusted server. OriginWeave therefore keeps MCP outside the product authority model. Active PR #168 implements only a bounded Rust `tools/call` routing/action-policy foundation for that exact generation; the complete transport, request-metadata, discovery, OAuth, browser, secret, and persistence adapter remains planned and cannot be inferred from the core routing primitive. @@ -48,19 +48,21 @@ object with enumerated architecture/bitness/platform tokens, an at-most-32 ASCII brand-name limit, a non-empty brand list, and the draft's coherence rule that a non-mobile user agent reports an empty model (see ADR 0112). -The pinned 3 September 2026 WebDriver BiDi Working Draft exposes locale, media, +The historical/reference 9 September 2026 WebDriver BiDi Working Draft exposes locale, media, screen, user-agent, viewport, and time-zone emulation commands under the immutable -publication `https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/`. The screen -shape contains width and height but not color depth, and locale accepts one value -rather than an ordered language list, so neither proves the corresponding complete -OriginWeave surface. The draft also does not define a hardware-concurrency -override. Chromium's tip-of-tree DevTools Protocol exposes -`Emulation.setHardwareConcurrencyOverride` as Experimental and warns that -tip-of-tree commands can change without notice. OriginWeave therefore records -required presentation surfaces in a protocol-neutral Rust admission contract; -the adapter records those four complete standard surfaces as protocol -capabilities, while the reusable-context plan emits only two typed command -intents—viewport/DPR and timezone—bound to one bounded opaque browsing context. +publication `https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/`. Publication +currentness has since advanced to the 16 September Working Draft with 14 September +as the previous publication, while OriginWeave's execution compatibility remains +separately pinned to the 3 September Working Draft. The screen shape contains width +and height but not color depth, and locale accepts one value rather than an ordered +language list, so neither proves the corresponding complete OriginWeave surface. The +draft also does not define a hardware-concurrency override. Chromium's tip-of-tree +DevTools Protocol exposes `Emulation.setHardwareConcurrencyOverride` as Experimental +and warns that tip-of-tree commands can change without notice. OriginWeave therefore +records required presentation surfaces in a protocol-neutral Rust admission contract; +the adapter records those four complete standard surfaces as protocol capabilities, +while the reusable-context plan emits only two typed command intents—viewport/DPR and +timezone—bound to one bounded opaque browsing context. Cleanup authority is asymmetric. Nullable viewport and timezone operations can restore those adapter-owned overrides on a reusable context, so generic cleanup @@ -266,7 +268,11 @@ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.o World Wide Web Consortium. (2025, September 25). *Mitigating browser fingerprinting in Web specifications*. https://www.w3.org/TR/fingerprinting-guidance/ -World Wide Web Consortium. (2026, September 3). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/ +World Wide Web Consortium. (2026, September 9). *WebDriver BiDi* (W3C Working Draft; historical/reference publication). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/ + +World Wide Web Consortium. (2026, September 3). *WebDriver BiDi* (W3C Working Draft; runtime-qualified compatibility pin). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/ + +OriginWeave. (2026, September 17). *WebDriver BiDi publication-current receipt*. `docs/traceability/webdriver-bidi-publication-current.md` World Wide Web Consortium. (2026). *WebDriver BiDi* (Editor's Draft). https://w3c.github.io/webdriver-bidi/ diff --git a/docs/doctoring/browser-session-recovery-settlement.md b/docs/doctoring/browser-session-recovery-settlement.md new file mode 100644 index 000000000..a418fa299 --- /dev/null +++ b/docs/doctoring/browser-session-recovery-settlement.md @@ -0,0 +1,95 @@ +# Browser Session recovery settlement boundary + +Status: active-PR implementation evidence for #317. Source implementation exists, but this document does not claim protected-main adoption, executable GREEN, browser acceptance, or release readiness until the exact head passes repository gates. + +## Problem + +`BoundBrowserSessionRecovery

` preserves the exact consumed adapter without exposing raw `P`. Recovery command execution and recovery completion are deliberately separate: an adapter return or browser command ACK is not proof that remote ownership is absent or reconciled. + +The settlement boundary therefore lets a protocol owner such as #316 independently qualify browser evidence, submit it against one Browser Session-issued `RecoveryFact`, and retire only that exact uncertainty after the retained adapter verifies the proof. + +Two follow-on capability gaps were found while hardening this boundary. + +First, the final settlement could move the aggregate to terminal `Ended` while `BoundBrowserSessionRecovery

` still exposed generic recovery I/O. That route is now closed by a pre-I/O lifecycle-state gate returning `RecoveryContextOperationError::RecoveryClosed`. + +Second, the still-open recovery command path originally needed only the aggregate recovery state and an adapter-defined operation. `RecoveryContextOperationRequest` then copied **both complete recovery ledgers** into the adapter request. That meant one recovery command could reach the retained adapter without naming the exact unresolved fact that justified it and could observe unrelated sibling facts. The current repair requires a Browser Session-issued current `RecoveryFact` for every operation and sends only that selected fact to the adapter. + +## Constraints and invariants + +Browser Session owns deterministic lifecycle state and exact fact validation/consumption. WebDriver BiDi remains an adapter and evidence source; protocol ids, tuple state, event ordering, command ACKs and liveness conclusions do not become Browser Session policy authority. + +The implementation preserves these invariants: + +- one opaque `RecoveryFact` addresses exactly one current identity-oriented or create-attempt recovery fact; +- the handle binds Browser Session id, process-local incarnation, ledger kind, index and current recovery revision; +- every generic recovery operation supplies a current `RecoveryFact` plus adapter-defined operation; +- foreign operation facts fail as `RecoveryContextOperationError::AuthorityMismatch` before adapter I/O; +- stale, replayed, shifted or out-of-range operation facts fail as `RecoveryContextOperationError::StaleFact` before adapter I/O; +- `RecoveryContextOperationRequest` contains only the selected fact. It never snapshots sibling recovery vectors; +- operation success/failure leaves lifecycle state and both recovery ledgers unchanged; +- the same current fact may authorize retry attempts until settlement advances the revision; +- settlement uses the same session/incarnation/revision/exact-address validation before proof-verifier I/O; +- successful settlement increments the revision, invalidating every handle issued before that mutation; +- failed proof verification leaves lifecycle state and both ledgers unchanged; +- identity-oriented `BrowserSessionRecoveryEvidence` and transaction-oriented `DisposableContextCreateRecoveryEvidence` remain separately addressable and retired; +- owned-context settlement retires only the exact matching uncertain hot record; candidate/partial-create evidence cannot consume an independently owned context merely because values alias; +- partial settlement preserves unrelated sibling facts; +- when both recovery ledgers and uncertain ownership are empty, the aggregate reaches terminal `Ended` and never recreates ordinary or generic recovery-command authority; +- terminal operation attempts return `RecoveryClosed` before the retained adapter is called. + +## Alternatives rejected + +Treating `RecoveryContextOperationPort` success as settlement is rejected because transport/protocol command completion is not independent proof of remote destruction or reconciliation. + +Allowing `execute_recovery_context_operation(operation)` without a fact is rejected because aggregate recovery state alone is too broad to authorize retained-adapter I/O. + +Copying both recovery ledgers into every `RecoveryContextOperationRequest` is rejected because it violates least authority and purpose limitation: the adapter learns sibling uncertainty that the selected operation does not need. + +Keeping generic recovery I/O callable after complete settlement is rejected because the retained adapter would remain an ambient browser-I/O capability after its recovery purpose ended. + +Passing a raw vector index is rejected because removal of one fact can make an old index address a different sibling. A current-revision opaque handle makes index-shift replay fail closed. + +Allowing the adapter to delete Browser Session evidence directly is rejected because it moves domain ownership truth into an adapter and makes protocol data authoritative over policy state. + +Returning from recovery custody to ordinary `BoundBrowserSession

` is rejected because reconciliation must not resurrect create, presentation, navigation or cleanup authority after uncertainty crossed the recovery boundary. + +## Test-first contract and source repair + +`recovery_exact_fact_settlement.rs` established proof-bearing exact-fact settlement: sibling preservation, stale replay rejection, foreign-fact pre-I/O rejection, verifier-failure non-mutation, independent identity/create-attempt ledgers, and terminal `Ended` without authority resurrection. + +`recovery_operation_terminal_closure.rs` established that generic recovery I/O works while a current recovery purpose exists, but final exact-fact settlement closes the command route before another adapter call. + +The current hardening adds `recovery_operation_exact_fact_scope.rs`. Its structural RED required the command API to accept `RecoveryFact`, disclose only the selected fact to `RecoveryContextOperationPort`, reject a fact issued before another settlement as `StaleFact` before operation I/O, and reject a foreign Browser Session fact as `AuthorityMismatch` before operation I/O. + +Production `recovery.rs` now shares exact-fact selection logic between command execution and settlement. `RecoveryContextOperationRequest` uses optional selected identity/create-attempt evidence fields rather than full vectors. `recovery_same_adapter_operation.rs` covers both ledger kinds while preserving the rule that command success or failure is non-settling. The terminal fixture now passes the same exact fact to the command before settling it and confirms `RecoveryClosed` takes precedence after terminal closure. + +Repository execution remains the next gate. Until the current exact head actually runs and passes repository contracts, rustfmt, locked tests, strict Clippy, rustdoc and production coverage, this is source-level repair evidence rather than executable GREEN. + +## Validation order + +For `execute_recovery_context_operation(fact, operation)`: + +1. recovery custody must still be `RecoveryRequired` or `TransportLost`; otherwise `RecoveryClosed`; +2. exact Browser Session id and incarnation must match; +3. recovery revision must still match; +4. the fact must still address a current entry in its ledger; +5. only then is `RecoveryContextOperationRequest` built and the retained adapter invoked. + +Steps 2–4 map to `AuthorityMismatch` or `StaleFact` and occur before adapter I/O. The request carries only the selected fact. Adapter return does not mutate recovery state. + +For `settle_recovery_fact(fact, proof)`: + +1. exact Browser Session id and incarnation; +2. current recovery revision and exact current ledger/index fact; +3. next-revision capacity; +4. retained-adapter proof verification; +5. exact evidence retirement and, for owned-context evidence, exact uncertain ownership retirement; +6. monotonic revision advance and terminal `Ended` only when no uncertainty remains. + +No proof-verifier call occurs before the exact-fact checks succeed. A failed verifier consumes nothing. A successful mutation invalidates all previously issued fact handles. + +## Ownership handoff + +#317 owns generic same-adapter recovery custody, exact-fact-scoped recovery commands, proof-bearing exact-fact settlement and terminal closure. #316 remains responsible for deciding what WebDriver BiDi observation constitutes acceptable proof, pending/accepted/quarantined tuple correlation, event replay handling, remote liveness and concrete recovery-command semantics. + +The dependency order remains #317 exact-head executable GREEN → #318/#321 acceptance → ordinary non-force #316 adoption → pinned-Chromium recovery and browser-observed post-condition evidence. Any implementation that copies #316 protocol tuple state into Browser Session, treats command ACK as proof, leaks sibling recovery facts into unrelated commands, or preserves generic adapter I/O after terminal recovery violates this boundary. diff --git a/docs/traceability/browser-session-cargo-build-std-authority.md b/docs/traceability/browser-session-cargo-build-std-authority.md new file mode 100644 index 000000000..2e6fcc800 --- /dev/null +++ b/docs/traceability/browser-session-cargo-build-std-authority.md @@ -0,0 +1,41 @@ +# Browser Session Cargo `build-std` authority + +## Problem + +OriginWeave's Browser Session trusted-adapter boundary reviews the repository-owned Cargo production package/source closure and the companion compiler-authority contract reviews Git-owned Cargo execution and input overrides. Cargo also permits `-Z` features to be configured in `.cargo/config.toml` under `[unstable]`. In particular, `build-std` changes a build from consuming the installed pre-built standard library to compiling selected standard-library crates from source as part of the crate graph, while `build-std-features` changes the features used for that standard-library build. + +That changes compiler inputs and supply-chain provenance without changing the Browser Session package manifests or Rust production-source closure. Treating it as an ordinary build preference would therefore let repository-owned configuration widen the reviewed toolchain/input boundary. + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. +- `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the owner for Git-owned Cargo-selected compiler, rustdoc, linker, toolchain, and external-input authority. +- This slice does not attempt to attest the ambient Rust toolchain, installed `rust-src`, runner image, or direct command-line `-Z` flags. +- Unrelated unstable Cargo settings are not blanket-banned solely because they live under `[unstable]`; only settings that alter the reviewed standard-library input boundary are classified here. + +## Authoritative evidence + +The Cargo Book's current unstable-features reference states that anything configurable with a `-Z` flag can also be set in `.cargo/config.toml` under `[unstable]`, and gives `build-std = ["core", "alloc"]` as an example. The same reference states that `build-std` compiles the standard library from source as part of the crate graph, requires the `rust-src` component and nightly Cargo/rustc, and may select the standard-library crates to build. `build-std-features` configures the features enabled for that standard-library build. + +Primary references: + +- Cargo Book, *Unstable Features — build-std*: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std +- Cargo Book, *Unstable Features — build-std-features*: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#build-std-features + +## RED → repair + +RED `5a63a00042b9f80fb905167b28ba736b71d65d39` adds a focused contract that routes repository-owned `[unstable] build-std` and `build-std-features` settings through the existing compiler-authority owner. It covers list and boolean `build-std` forms plus `build-std-features`, and keeps an unrelated `mtime-on-use` setting as an allowed control. + +Repair `e524af3a2e316a3b124a25aac8392760c25a12ba` adds the minimal classification to the existing parsed-config owner. Active `build-std` and `build-std-features` settings fail closed as `unstable_keys`; explicit `false` or an empty list do not widen the input boundary. No second Cargo topology or configuration scanner is introduced. + +## Decision and security effect + +Repository-owned Cargo configuration may not select source-built standard-library inputs for the Browser Session production boundary until the same reviewed change supplies a versioned toolchain/source provenance contract. This prevents a Git-owned `.cargo/config*` change from silently replacing the pre-built sysroot assumption with source-built `core`, `alloc`, `std`, `proc_macro`, or feature-modified standard-library artifacts. + +The fail-closed rule is intentionally narrower than banning every unstable Cargo feature. A future need for `build-std` must identify the exact Rust toolchain, `rust-src` source identity, selected standard-library crates/features, target specification, resulting artifacts, and reproducible attestation before the rule is relaxed. + +## Residual execution/release provenance + +This repository contract does not prove ambient execution state. Remaining owner surfaces include direct Cargo CLI `-Z build-std` / `-Z build-std-features`, ancestor or `$CARGO_HOME` configuration, runner/container images, installed nightly Cargo/rustc and `rust-src`, custom target specifications, and other unstable features that can alter compiler/toolchain authority. Those require CI/release environment controls and, where repository-owned configuration gains such authority, additional focused fail-closed contracts. + +No hosted repository execution, protected-head GREEN, immutable release, or browser-observed acceptance is claimed by this source-semantic repair alone. diff --git a/docs/traceability/browser-session-cargo-build-surface-boundary.md b/docs/traceability/browser-session-cargo-build-surface-boundary.md new file mode 100644 index 000000000..ecbbd6e00 --- /dev/null +++ b/docs/traceability/browser-session-cargo-build-surface-boundary.md @@ -0,0 +1,56 @@ +# Browser Session Cargo build-surface boundary + +Status: Draft evidence on PR #317. This document does not claim protected-main shipment or executable exact-head GREEN. + +## Problem + +The Browser Session trusted-adapter contract reviews production Cargo package topology and Rust source provenance before allowing lifecycle-SPI references, Browser Session dependencies, or caller-selected lifecycle binding. That closure previously covered normal dependencies, target-specific dependencies, default Rust source, and explicit `[lib].path` / `[[bin]].path`, but did not govern Cargo build scripts or build dependencies. + +Cargo runs a package-root `build.rs` by default before compiling the package. `[package] build` can select another build-script path or disable build scripts, and `[build-dependencies]` plus target-specific build dependencies supply code to that build script. Build scripts may generate Rust source in `OUT_DIR` that the package later compiles with `include!`. A future production package could therefore introduce privileged generated code without that code being fixed by the repository source-closure contract. + +## Decision + +OriginWeave fails closed on Cargo build surfaces in the Browser Session production package closure until generated-source provenance is explicitly modeled. + +For every manifest returned by canonical `_production_package_manifests(root)`: + +- non-empty top-level `[build-dependencies]` is rejected; +- non-empty target-specific `build-dependencies` is rejected; +- a default package-root `build.rs`, including a symlink, is rejected; +- a non-empty string `[package] build = "..."` is rejected; +- unsupported non-null `package.build` values are rejected; +- `package.build = false` is allowed because it explicitly disables Cargo build-script discovery. + +This is a repository-security contract, not a statement that Cargo build scripts are inherently unsafe. OriginWeave is declining a source-generation surface until it can bind generated bytes, generator inputs, build dependencies, toolchain, target/host distinction, and resulting artifacts to reproducible exact-head evidence. + +## RED and repair + +Structural RED: `022b63d130bb0901c8ef8bc18e5062eb987350f5` adds hostile fixtures for default `build.rs`, custom `[package] build`, top-level `[build-dependencies]`, and target-specific build dependencies. Before the repair, canonical production package discovery admits each fixture. + +Minimal repair: `c917970fb939e35ccb0adb920754f8208aed46c1` adds `_manifest_build_dependency_sections` and `_assert_no_production_build_surfaces` to the correction-owning `tests/test_browser_session_trusted_adapter_boundary.py` and invokes the guard for every production manifest discovered by `_production_package_manifests`. + +Independent review on PR #317 classified the omission as a security misconfiguration (CWE-693) and confirmed the repair statically. Hosted executable evidence remains separate because #317 is Draft. + +## Rejected alternatives + +### Treat `build.rs` as ordinary repository source only + +Rejected. The script itself may be reviewed while its generated `OUT_DIR` bytes and generator inputs remain outside the canonical production-source evidence contract. + +### Permit build dependencies but scan only local ones + +Rejected for this slice. Registry or Git build dependencies can influence generated code just as local build dependencies can. Allowing only part of the generator dependency graph would create a misleading provenance claim. + +### Ban build scripts permanently + +Rejected. A future browser adapter may have a legitimate need for generated bindings or native integration. At that point the same reviewed delta must introduce a generated-source provenance/reproducibility contract before widening this boundary. + +## Follow-up + +If a Browser Session-dependent production package needs a build script, the owner must first define the generator contract: immutable inputs and generator dependencies, exact toolchain/host/target identity, `OUT_DIR` artifact hashing, reproducible rebuild evidence, generated-source inclusion provenance, and rollback/release evidence. The build-surface prohibition may then be narrowed in the same reviewed exact tree; it must not be bypassed with workflow-only generation or mutable pre-generated artifacts. + +## References + +The Cargo Project Developers. (2026). *Build Scripts*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/build-scripts.html + +The Cargo Project Developers. (2026). *Specifying Dependencies: Build dependencies*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html diff --git a/docs/traceability/browser-session-cargo-cfg-target-links-override-authority.md b/docs/traceability/browser-session-cargo-cfg-target-links-override-authority.md new file mode 100644 index 000000000..2e1585e6e --- /dev/null +++ b/docs/traceability/browser-session-cargo-cfg-target-links-override-authority.md @@ -0,0 +1,42 @@ +# Browser Session Cargo cfg-target links-override authority + +## Problem + +The Browser Session compiler-authority contract treated every nested table below `[target.]` as a Cargo `links` build-script override. That is correct for tuple target tables loaded through Cargo `TargetConfig`, but not for `target.'cfg(...)'` tables. + +Cargo's current target loader uses a distinct `TargetCfgConfig` for `target.'cfg(...)'`. That type admits `runner`, `rustflags`, `rustdocflags`, and `linker`; remaining keys are captured as `other` and Cargo warns that they are unused. By contrast, tuple targets are loaded through `TargetConfig`, whose unknown nested tables are parsed as `links_overrides` and can suppress a package build script while substituting configured build output. + +Treating the two grammars as identical created a security-contract false positive: a nested table under `target.'cfg(...)'` was rejected as executable provenance authority even though current Cargo does not consume it as a links override. + +## Constraint and owner boundary + +`tests/test_browser_session_cargo_compiler_authority_contract.py` remains the canonical owner for repository-selected Cargo compiler, rustdoc, linker, target and build-script-output authority. The trusted-adapter boundary remains the single writer for production Cargo package/source topology. This repair does not create a second Cargo parser and does not broaden any tuple-target authority. + +## RED → repair + +- RED `a108eb42996a67db3f8809e17cc3166caa6b197c` adds a control for `[target.'cfg(unix)'.review_bypass]` and a paired tuple-target hostile case. Before the repair, the cfg-target control is misclassified as `links build-script override`. +- Repair `9b6191ae6e699e68d4b25e8298a18ffcc0c611b5` preserves linker/runner/rustflags/rustdocflags classification for cfg targets, but only derives nested `links` override authority when the target key is not `cfg(...)`. +- The tuple-target hostile case remains fail closed, so the repair removes a false positive without weakening build-script-output provenance. + +## Primary-source trace + +Cargo source revision `8814ead110e36ed8fdcf1fdd4009baf82bd78523`, `src/context/target.rs`: + +- `TargetCfgConfig` defines the cfg-target grammar and records unmatched fields in `other`. +- `load_target_cfgs` warns for each `other` key instead of interpreting it as a build-script override. +- `TargetConfig` carries `links_overrides` for tuple targets and host config. +- `load_config_table` calls `parse_links_overrides` only for the selected tuple/host target table. + +Cargo Book configuration documentation separately documents `target..` as the build-script override form, while `target.` is documented for runner/rustflags/rustdocflags/linker behavior. + +## Decision + +Selected: model Cargo's two target grammars explicitly at the narrow classification point. `target.'cfg(...)'` retains all typed compiler/linker authority checks, but nested unknown tables are not promoted to `links` authority. + +Rejected: fail closed on every nested cfg-target table. It is conservative but semantically incorrect against current Cargo and creates avoidable buyer-facing false positives. + +Rejected: remove nested-table detection globally. That would create a real tuple-target provenance bypass because `target..` can replace build-script output. + +## Risk and follow-up + +This contract is source-semantic evidence, not proof that every future Cargo release preserves the same grammar. Cargo source/documentation revisions must be rechecked before changing the pinned toolchain or admitting a newer Cargo behavior. Hosted exact-head tests and security checks remain required before protected-main integration. diff --git a/docs/traceability/browser-session-cargo-compiler-authority.md b/docs/traceability/browser-session-cargo-compiler-authority.md new file mode 100644 index 000000000..1014de161 --- /dev/null +++ b/docs/traceability/browser-session-cargo-compiler-authority.md @@ -0,0 +1,134 @@ +# Browser Session Cargo compiler authority traceability + +Status: Draft contract evidence on PR #317. This document does not claim executable repository/security GREEN. + +## Problem + +OriginWeave already fails closed on production `build.rs`, Cargo build dependencies, dependency-source overrides, repository-external source paths, and unmodeled Rust source indirection. The remaining Git-owned Cargo configuration boundary is execution and external-input selection around Rust compilation, documentation, linking, and test/run execution. + +Cargo permits repository configuration to replace `rustc` with `build.rustc`, execute a program in front of `rustc` with `build.rustc-wrapper`, add a workspace-only wrapper with `build.rustc-workspace-wrapper`, and replace the documentation generator executable with `build.rustdoc`. Cargo target configuration also permits a matching target table to choose `linker`, the executable used for linking, and `runner`, the wrapper used for `cargo run`, `cargo test`, and `cargo bench`. In addition, `build.rustflags` and matching target `rustflags` are passed to `rustc`, while `build.rustdocflags` and matching target `rustdocflags` are passed to `rustdoc`. + +Rust documents `-C` and `--codegen` as equivalent short and long codegen-option interfaces. The `linker` codegen option directly selects which linker executable rustc invokes. Rust also documents `link-arg` and `link-args` as arguments appended to the linker invocation. On Unix-like targets where a C compiler is the linker driver, Rust documents that `-Clink-arg=-fuse-ld=$value` is passed to the driver after rustc's own linker-feature arguments and therefore generally takes priority when the driver chooses the actual linker. A repository-owned Cargo flag can therefore re-select the linker behind the nominal compiler driver without using `target..linker` or `-C linker=`. + +The same nominal-driver boundary has another executable-selection path. Rust documents that Unix-like targets commonly use `cc` or `clang` as the linker driver. GCC documents `-Bprefix` as the first search prefix for driver subprograms including `ld`; if the requested program is found there, that executable is run before the standard prefixes or `PATH` lookup. Repository-owned `-C link-arg=-B...` or `--codegen=link-args=-B ...` can therefore redirect the compiler driver to a different linker executable without changing the nominal driver or using `-fuse-ld=`. + +GCC expands `@file` response-file arguments in place, recursively. A repository-owned `-C link-arg=@tools/linker.rsp` can hide `-B...`, `-fuse-ld=...`, or another driver option from a scanner that only inspects the visible Cargo flag. GNU-compatible forwarding does not make that provenance safe: `-Wl,@file`, `--for-linker=@file`, and `-Xlinker @file` delegate parsing to the linker after the compiler-driver surface. Until response-file contents, containment, recursion, and effective driver/linker semantics are represented as reviewed provenance, both driver-level and forwarded linker-level `@file` arguments are opaque extensions of the execution/input boundary and fail closed. + +GNU `ld` can dynamically load linker plugins with `-plugin name`, and GCC forwards explicit linker options through `-Wl,option`, `--for-linker=option`, or `-Xlinker option`. A repository-owned Cargo flag can therefore leave the nominal compiler and linker executables unchanged while injecting a repository-selected shared object into the linker process. That is executable-code provenance, not ordinary linker tuning. + +GNU linker scripts are explicit native-input authority. `-T`/`--script` can select a script whose `INPUT(...)`/`GROUP(...)` directives add object or archive inputs outside the reviewed Rust production-source closure. The shared Cargo linker-argument classifier fails closed on direct script selection and on `-Wl,`, `--for-linker=`, or `-Xlinker` forwarding of those options. + +Ordinary positional linker inputs are now part of the same authority boundary. GNU `ld` treats non-option arguments as input files and may parse an unrecognized input as an implicit linker script. The current shared parser therefore treats every unconsumed non-option token in the modeled direct/GNU-compatible grammar as provenance-bearing input, including suffix-bearing native objects, path-shaped extensionless inputs, and bare extensionless filenames. Explicitly modeled harmless option operands, currently including `-z `, are consumed by arity before positional classification. + +GCC itself is a driver that invokes preprocessing, compilation, assembly, and linking subprocesses according to spec strings. GCC documents that command-line `-specs=file` overrides built-in specs, while the driver also accepts the option and its file argument as separate argv tokens. The spec-file format can override named spec strings or include other spec files. Repository-owned Cargo `link-arg`/`link-args` can therefore alter which subprocesses or switches the nominal linker driver uses with either `-specs=file` or `-specs file`, without changing the visible `linker=` setting. + +Rustc and rustdoc external-input flags are also provenance-bearing. Git-owned Cargo `rustflags` and `rustdocflags` can use `-L` to widen library search paths, `--extern` to select an external crate, or `--sysroot` to replace the Rust system root used to resolve distribution-provided compiler/documentation inputs; rustc `-l` can request native libraries. These repository-owned forms are fail-closed until exact artifact provenance is modeled. This is distinct from Cargo's own dependency wiring: the canonical production topology remains owned by `tests/test_browser_session_trusted_adapter_boundary.py`. + +If any of these settings enters a reviewed Browser Session production workspace without a separate provenance contract, the effective build/test/documentation execution or external-input path is no longer represented by the existing exact-tree source closure. + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology and dependency-source override discovery. +- This contract does not authorize a future wrapper, custom compiler, custom rustdoc executable, linker, runner, generated source path, response file, linker plugin, GCC specs file, linker script, external crate/native input, repository-selected sysroot, or adapter implementation. +- Environment-owned `RUSTC`/`RUSTC_WRAPPER`/`RUSTDOC`/`RUSTFLAGS`/`CARGO_ENCODED_RUSTFLAGS`/`RUSTDOCFLAGS`/`CARGO_ENCODED_RUSTDOCFLAGS` authority belongs to the CI/runtime owner. This repository contract covers Git-owned `.cargo/config.toml` and `.cargo/config` files. +- Ordinary Cargo settings, rustflags, rustdocflags, and linker arguments that do not select a modeled executable, alter modeled driver subprocess authority, dynamically load modeled linker code, or widen a modeled external-input surface are not rejected merely because they occur under `[build]` or `[target]`. +- Command-line `cargo rustc`/`cargo rustdoc` flags, environment/toolchain configuration, non-GNU linker/plugin/control-file mechanisms, non-`-B` driver/tool search-path mechanisms, and linker option grammars/operands not yet explicitly modeled remain separate review surfaces. + +## RED + +Commit `ba9c9299d9a81d26ec68ed1c39a9dc11b08ddcb6` added a hostile repository fixture with `build.rustc-wrapper`. The predecessor source/config boundary rejected Cargo source overrides and build scripts but did not classify compiler-wrapper execution. + +Commit `a39caf95a38862f4cb4bcb68115b5e385bd6c26e` added a hostile `build.rustdoc` fixture. The predecessor compiler-authority contract accepted that setting because its execution-key set covered only `rustc` and the two rustc wrapper keys. + +Commit `883ad62125af37e9afb2551803267284e21b7ea3` added target-specific hostile fixtures using `target..linker` and `target..runner`. The predecessor contract inspected only `[build]`. + +Commit `8b9ab4c34033b9e5990caad79169351e3d771039` added hostile Cargo-owned rustflags that selected `rustc -C linker=`. Commit `b0489000709243b27a9c849d2cada8e5fadd254e` added the corresponding rustdocflags path. Focused review of exact `a3434ebf7d8169a6cf5276d983d4a0c978a9720a` then found the long `--codegen` bypass, preserved by RED `053b6cacd0d7d36dc619165aada99c1ac485b043`. + +Commit `0b457b3936ef3fdd0546de06f279fc5cb2756e13` preserves the `-fuse-ld=` driver-selection RED. Commit `ed5661090417e1b95943720f101f54a3a925ac96` preserves the GNU-compatible `-B` driver-search RED. Commit `659e0b3914f19fd19c2d5d9dbeb9f40a43c00517` preserves the driver response-file RED. + +Commit `6e94d6b86ac96a677ad6195c818bc94f2199e77b` preserves linker-plugin execution REDs through GNU-compatible forwarding. Commit `f778fe6a3f5c0b5f97e1eee15ef42ef6b25fcc71` preserves joined GCC `-specs=` RED; focused review of exact `c9440bdb2b1b610b6a52024a176acddbcd5e6cc5` identified the split `-specs ` bypass, preserved by `eb1ef86f6456e99bd581599b4bb474f9fa48fc02`. + +Commit `43375ef80b1bcf6a0421f900a2f9cbf519741849` preserves explicit GNU linker-script native-input REDs. Focused review of exact `c0204371a1d8e06e3b68ed07437d5581f9a94762` found the forwarded linker-response-file gap, preserved and repaired by `b0af9ed4251d4195c4f71893b9550cdbb38ab0b8` with coverage for `-Wl,@file`, `--for-linker=@file`, and `-Xlinker @file`. + +Commit `657428dd607c2a384f95865ff59caba704a899d9` preserves repository-owned rustc `-L`/`-l` external-input REDs, and `ad5090dfb1e6de9eb1e2875365fa2b65ad37a5d9` preserves the equivalent compiler-driver forwarding gap. Commit `dbd9faa623f01652c2e75904af8bec614c2e6fc9` preserves Git-owned Cargo `--extern` external-crate input authority. + +Commit `e547203368da2aec62e2c94ab491eb0749d7d7b5` preserves suffix-bearing positional native-input REDs. Commit `8f5e49f5fe63d99773d25f13a3ab50648e02ac92` preserves the path-shaped extensionless input gap. Commit `ed86b335b4aa6cde223fe2e614bb26d39f789d8a` proves the remaining bare extensionless filename gap across direct and GNU-compatible forwarding forms. + +Commit `5928b1a614c8620203675b609b75f5489338e06d` preserves the rustdoc external-input gap: build-level `rustdocflags --extern` and target-level `rustdocflags -Lnative=...` passed the predecessor because external-input classification was applied only to `rustflags`. + +Commit `3943f268395180d199992709393190510ae48b2d` preserves the repository-selected sysroot gap. Split and equals-form `--sysroot` values in build/target `rustflags` and `rustdocflags` passed the predecessor because the external-input classifier covered `--extern`, `-L`, and `-l` but not Rust sysroot selection. + +These are source-level RED fixtures; no hosted-run result is inferred from the Draft branch. + +## Decision and repair + +Commit `80aaa562672211582d5b2de69edc79a984559fb3` introduced a separate execution-authority contract that consumes the canonical trusted-adapter production topology contract and then inspects Git-owned Cargo configuration. `345759105a0f0d2e88142df9961ea724b1055734` extended it to `build.rustdoc`; `e7390cdb12c483570940411c857554540f754763` added matching target `linker` and `runner` rejection. + +Commit `743a5321bb72d83541b34f12ce83628f95f581f6` closed Cargo-owned rustflags that select a linker. Commit `e157b9c5f33467425df794f42e704503de697232` reused the same narrow parser for Cargo-owned rustdocflags. Commit `7ee4b253ff4e213e949468274d126db214a63e4a` closed the review-discovered long `--codegen` bypass. + +Commit `d9e7d8ab4047bb25d3c6db0e195ec06240495aa9` adds `-fuse-ld=` driver selection. Commit `17a665ca0c895635cc52f3014a43cb9dea86e1b1` adds `-B` driver-program search selection. Commit `92ce887209f58b33ecd478ee09212bda70890894` classifies driver-level `@file` as opaque provenance. + +Commit `0ae660fa0c182dc9776aef95d9ed2d2bea7bfa2a` keeps one parser across multiple `link-arg` values and GNU-compatible `-Wl,`, `--for-linker=`, and `-Xlinker` forwarding while rejecting plugin loading. Commit `29dd6bb6548d4e003970743e7602753c3505cf83` adds joined `-specs=` authority; `688680c92e48bc5ad999327c46b366e5d27eeb5f` closes split `-specs `. + +Commit `8975224e86ea5ef9821d168efeaafa20702ec659` adds explicit GNU linker-script selection. Commit `b0af9ed4251d4195c4f71893b9550cdbb38ab0b8` closes GNU-forwarded linker response files without banning ordinary forwarded controls. + +Commit `d3a1790c50c393e0328854a2dd7fe4d9aaf3d5c4` closes top-level Git-owned Cargo rustc `-L`/`-l`; `a0f8525b57837ac119ef7b2af9c1daa759ef12f4` extends the same external-input classifier to driver/direct-forwarding forms. Commit `098a596029c0cf339c070604a265593540822956` closes Git-owned Cargo `--extern` external-crate inputs. + +Commit `e73d221cf28aa25ae6fbd941db95d5d923c7e883` first closes common suffix-bearing positional native inputs. `cb95d5d37050bb7da70f1782da2e63a9b4583734` closes path-shaped extensionless inputs. Commit `5f070bf0b87ae513cf06badda29914e579f850ee` replaces those shape heuristics with an arity-aware direct-linker parser: every unconsumed non-option token in the modeled direct/GNU-compatible grammar fails closed, while explicitly modeled option operands such as `-z relro` remain allowed. + +Commit `e8edb487b779a1c4ff22bf2ca4c62ae7e52abd8b` applies the existing external-input classifier to build- and target-level `rustdocflags`, closing repository-owned rustdoc `--extern`/`-L` forms without adding another Cargo topology/config scanner. The dedicated rustdoc trace is `docs/traceability/browser-session-rustdoc-external-input-authority.md`. + +Commit `f20401f0368ac9ab5f9756fc285972791526887c` extends that same external-input classifier to split and equals-form `--sysroot` for both rustflags and rustdocflags. The focused evidence is `tests/test_browser_session_sysroot_input_contract.py`; `docs/traceability/browser-session-sysroot-input-authority.md` records the toolchain/sysroot provenance boundary without creating another Cargo topology owner. + +The modeled fail-closed execution/input-authority surfaces now include: + +- `build.rustc`, `build.rustc-wrapper`, `build.rustc-workspace-wrapper`, and `build.rustdoc`; +- `target..linker` and `target..runner`; +- build/target `rustflags` and `rustdocflags` that select a linker through `-C`/`--codegen`; +- driver linker reselection through `-fuse-ld=` or `-B`; +- driver/linker response files (`@file`) in the modeled direct/GNU forwarding forms; +- linker plugin loading, explicit `-T`/`--script`, GCC `-specs=` / `-specs `, and modeled rustc-managed native-tool selectors; +- Git-owned Cargo rustc/rustdoc external-input widening through modeled `-L`, `-l`, `--extern`, and `--sysroot` forms; +- direct/GNU-forwarded positional inputs, including suffix-bearing objects/archives, path-shaped extensionless inputs, and bare extensionless filenames/implicit-script candidates. + +A separate contract is retained instead of expanding Cargo package/source discovery because executable selection, driver subprocess authority, dynamically loaded linker code, and external-input argument authority are not package topology. A blanket rustflags/rustdocflags or linker-argument ban remains rejected because non-authority flags are common and do not justify widening this boundary. Path-only allowlists remain insufficient because they do not establish immutable executable/artifact identity, recursively expanded arguments/includes, transitive native inputs, behavior, or provenance. + +## Security effect and residual risk + +The repair closes the modeled Git-owned execution/input-provenance paths that could place an unmodeled executable between Cargo and `rustc`, replace rustdoc, choose/interpose the linker or runner, alter GCC driver subprocess rules, load linker code, inject explicit/implicit scripts or positional native inputs, widen external-library search paths, add external crates, or replace the Rust sysroot through repository-owned Cargo flags while the reviewed Rust source closure remained unchanged. + +It does **not** prove CI environment variables, direct `cargo rustc` / `cargo rustdoc` trailing arguments, runner images, ambient/default sysroot contents, rustup/toolchain installation state, external binaries, non-GNU linker/plugin/control-file grammars, non-`-B` driver/tool search-path mechanisms, or unmodeled arguments with equivalent semantics trustworthy. Those controls remain with their canonical CI/supply-chain owners or future focused contracts. Git-owned Cargo `--sysroot`, path-shaped and bare extensionless positional inputs, Git-owned Cargo `--extern`, and Git-owned Cargo rustdoc `--extern`/`-L` are no longer residual gaps in this repository-owned Cargo boundary. + +## Acceptance and follow-up + +1. Obtain independent current-head review of the newest sysroot-input, positional-input, rustdoc external-input, and lifecycle-contract repairs together with retained execution/input authority contracts. +2. After #229 exact-head required evidence becomes terminal, reconcile #317 by ordinary non-force ancestry while preserving all valid parent and child deltas. +3. Regenerate executable repository/security evidence on the reconciled exact head. +4. Review environment/direct-CLI injection, non-GNU control/input grammars, unmodeled option arities, and ambient toolchain/default-sysroot composition separately; add a contract only when a realistic execution/provenance escape is demonstrated. +5. If any blocked executable, response file, linker plugin, specs file, linker script, external artifact, or custom sysroot is required, replace fail-closed only with an explicit design covering immutable identity, recursive argument/source provenance, SBOM/attestation, rollback, and buyer-visible evidence. + +## References + +The Cargo Project. (n.d.). *Configuration*. *The Cargo Book*. Retrieved September 18, 2026, from https://doc.rust-lang.org/cargo/reference/config.html + +The Cargo Project. (n.d.). *cargo rustdoc*. *The Cargo Book*. Retrieved September 18, 2026, from https://doc.rust-lang.org/cargo/commands/cargo-rustdoc.html + +Free Software Foundation. (n.d.). *Directory options*. *Using the GNU Compiler Collection (GCC)*. Retrieved September 18, 2026, from https://gcc.gnu.org/onlinedocs/gcc/Directory-Options.html + +Free Software Foundation. (n.d.). *Link options*. *Using the GNU Compiler Collection (GCC)*. Retrieved September 18, 2026, from https://gcc.gnu.org/onlinedocs/gcc/Link-Options.html + +Free Software Foundation. (n.d.). *Overall options*. *Using the GNU Compiler Collection (GCC)*. Retrieved September 18, 2026, from https://gcc.gnu.org/onlinedocs/gcc/Overall-Options.html + +Free Software Foundation. (n.d.). *Spec files*. *GNU Compiler Collection (GCC) Internals*. Retrieved September 18, 2026, from https://gcc.gnu.org/onlinedocs/gccint/Spec-Files.html + +Free Software Foundation. (n.d.). *Plugins*. *GNU ld*. Retrieved September 18, 2026, from https://sourceware.org/binutils/docs/ld/Plugins.html + +Free Software Foundation. (n.d.). *Scripts*. *GNU ld*. Retrieved September 18, 2026, from https://sourceware.org/binutils/docs/ld/Scripts.html + +Free Software Foundation. (n.d.). *Implicit linker scripts*. *GNU ld*. Retrieved September 18, 2026, from https://sourceware.org/binutils/docs/ld/Implicit-Linker-Scripts.html + +The Rust Project Developers. (n.d.). *Command-line arguments*. *The rustc book*. Retrieved September 18, 2026, from https://doc.rust-lang.org/rustc/command-line-arguments.html + +The Rust Project Developers. (n.d.). *Command-line arguments*. *The rustdoc book*. Retrieved September 18, 2026, from https://doc.rust-lang.org/rustdoc/command-line-arguments.html + +The Rust Project Developers. (n.d.). *Codegen options*. *The rustc book*. Retrieved September 18, 2026, from https://doc.rust-lang.org/rustc/codegen-options/ diff --git a/docs/traceability/browser-session-cargo-environment-authority.md b/docs/traceability/browser-session-cargo-environment-authority.md new file mode 100644 index 000000000..3dea7b079 --- /dev/null +++ b/docs/traceability/browser-session-cargo-environment-authority.md @@ -0,0 +1,34 @@ +# Browser Session Cargo environment authority + +## Problem + +Repository-owned Cargo configuration is part of the reviewed Browser Session build provenance. Cargo's `[env]` table injects environment variables into processes it runs, including `rustc` invocations. Rust source can consume those values at compile time through `env!` and `option_env!`, and procedural macros execute during compilation with the compiler's resources. A Git-owned `[env]` entry can therefore change compiler-visible inputs or generated code without changing the reviewed Rust source or the existing `rustflags`/`rustdocflags` surface. + +## Authority and constraint + +`tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. `tests/test_browser_session_cargo_compiler_authority_contract.py` owns repository-selected compiler, rustdoc, linker, and compiler-environment authority. Supplemental contracts consume that owner rather than rediscovering Cargo configuration. + +Until OriginWeave has a purpose-bounded, versioned environment allowlist with exact consumer and artifact provenance, any non-empty Git-owned Cargo `[env]` table is fail-closed. An empty `[env]` table is permitted because it introduces no compiler-visible value. + +This policy covers both string values and Cargo's table form, including `force = true` and `relative = true`. The latter can turn a repository-relative value into an absolute path before it is exposed to Cargo-run processes. + +## RED → repair evidence + +RED `46c0c90d6162a43db3857186d4ab1731e4b0f42c` adds `tests/test_browser_session_cargo_environment_authority_contract.py`. It proves that ordinary string injection, forced replacement, and config-relative path injection were previously accepted by the canonical Cargo compiler-authority owner while retaining an empty `[env]` table as a control. + +Repair `67cd0f16ca5a9eeaa467ad32bbc55f60d9d1cb98` minimally extends the existing parsed-config owner. It records non-empty `[env]` keys as unmodeled compiler-environment authority and rejects them through the same Browser Session provenance error path. No second Cargo topology or config scanner is introduced. + +## Security and reproducibility effect + +A checked-in Cargo config can no longer change compiler-visible environment values outside the reviewed Browser Session build-input contract. This closes source-visible compile-time inputs consumed by `env!` / `option_env!` and reduces unreviewed environment available to compile-time code such as procedural macros. + +This repository-source contract does not prove the ambient execution environment. Shell variables, runner image configuration, `$CARGO_HOME` or ancestor Cargo configuration, command-line `--config`, CI-injected secrets, compiler/toolchain installation state, and environment inherited from the host remain CI/release/runtime provenance surfaces. They require canonical execution-environment controls rather than inference from Git source closure. + +## Primary references + +- The Rust Project. (2026). *The Cargo Book: Configuration — `[env]`*. https://doc.rust-lang.org/cargo/reference/config.html#env +- The Rust Project. (2026). *Rust core macro `env!`*. https://doc.rust-lang.org/core/macro.env.html +- The Rust Project. (2026). *Rust core macro `option_env!`*. https://doc.rust-lang.org/core/macro.option_env.html +- The Rust Project. (2026). *The Rust Reference: Procedural macros*. https://doc.rust-lang.org/reference/procedural-macros.html + +Cargo documents that `[env]` values are provided to build scripts and `rustc` invocations and that `force` and `relative` alter replacement and path-resolution behavior. Rust documents that `env!` and `option_env!` inspect environment variables at compile time, while procedural macros execute during compilation with the compiler's resources and build-script-like security concerns. diff --git a/docs/traceability/browser-session-cargo-host-config-authority.md b/docs/traceability/browser-session-cargo-host-config-authority.md new file mode 100644 index 000000000..f9826eb49 --- /dev/null +++ b/docs/traceability/browser-session-cargo-host-config-authority.md @@ -0,0 +1,56 @@ +# Browser Session Cargo host-config execution authority traceability + +## Decision + +Repository-owned Cargo configuration must not be able to introduce unreviewed host-side compiler, linker, runner, rustdoc, or build-script-link authority for Browser Session production builds. `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for repository-selected Cargo compiler/toolchain/execution and external-input authority; this dossier adds no second Cargo package/source topology scanner. + +The contract fails closed on authority-bearing `[host]` and `[host.]` settings: `linker`, `runner`, authority-extending `rustflags` / `rustdocflags`, and host build-script `links` overrides. Cargo's host configuration reuses `TargetConfig`; `load_host_triple()` selects `[host.]` when present and otherwise generic `[host]`, then `load_config_table()` parses the selected table's non-typed keys through `parse_links_overrides()`. A links override is authority even when its table is empty: Cargo constructs `BuildOutput::default()` and inserts the library name into `links_overrides`, so a matching package's build script is skipped despite there being no replacement output fields. Harmless typed host flags such as `-C opt-level=2` and `--document-private-items` remain permitted. + +## Problem and buyer/security effect + +Cargo's nightly `host-config` feature gives Git-owned configuration a distinct path for artifacts compiled or executed on the build host, including build scripts and other host artifacts. Cargo documents generic `[host]` and host-tuple-specific tables, with host-tuple settings taking precedence, and documents `host.runner` as the wrapper used to execute host build targets such as build scripts. Cargo's current `TargetConfig` is the configuration type for `[target]` or `[host]` and exposes `rustflags`, `rustdocflags`, `linker`, `runner`, and `links_overrides`; a links override suppresses the matching package build script and substitutes configured `BuildOutput`. + +The original Browser Session compiler-authority owner examined `[build]`, `[target]`, repository environment/config inclusion, unstable toolchain selectors, and profile rustflags/codegen backends, but ignored `[host]`. Later repairs added host authority and generic nested-map coverage. A subsequent review found that content-shape classification could misclassify a legitimate `[host.]` table containing only typed host settings as a links override, so the current classifier preserves typed `linker` / `runner` / `rustflags` / `rustdocflags` semantics and recurses into unknown nested maps rather than treating every first-level host table as an override. + +That repair exposed a narrower fail-open case. An empty nested table has no scalar payload for the recursive classifier to recognize, but Cargo still inserts an empty `BuildOutput` for that `links` key. Both `[host.review_bypass]` under the generic selected host table and `[host..review_bypass]` under a selected host-triple table can therefore suppress a matching build script while the repository guard reports no authority. Empty replacement output is not absence of authority; suppressing the build script is itself a build decision. + +## RED → repair evidence + +- **Structural RED `960d361a37d942937f9d2d88f9cd745265a77a90`** added a focused supplemental contract that calls the canonical compiler-authority owner and proved that generic host linker, host-tuple runner, and authority-extending host rustflags were not rejected, while unrelated host rustflags remained a control. +- **Minimal canonical repair `da6b14de3366c235b1b4c10d340e68477cd41c2a`** added `_configured_host_execution_authority()` to the existing compiler-authority owner, reusing existing target execution keys and rustc/linker/input classifiers. +- Review of Cargo's `TargetConfig` surface exposed host `rustdocflags` and host `links_overrides`. **Review-driven RED `66d8a535befaade759eba6f3f464499792d6bfaf`** added hostile rustdoc external-input and host-tuple links-override cases; **repair `d2830ddc28e8d6e70af54ae4dec09fd8d40dd3ec`** reused the existing classifiers and treated host build-script override tables as authority. +- **RED `6574faa0d7012dec8fad0f0a563599af90e89634`** exposed a generic `[host.review_bypass]` build-output table. **Repair `edfbd7402d7653374ad7ab5556b3952f73d0ad89`** closed that generic-host path. +- A later focused review found the broad generic-host repair could reject a legitimate host-triple table that contained only typed host settings. **RED `ad71ca1f70ca3e5220d59cdbd4dcab3b60610012`** fixed that false-positive expectation, and **repair `1e4c16d9ad1487f3a101dce699d52913277ad85d`** made the classifier preserve typed host settings while recursively classifying unknown nested build-output payloads. +- Fresh Cargo-source review then found that `parse_links_overrides()` starts each non-typed table with `BuildOutput::default()` and inserts it into `links_overrides` even when the table is empty. **Structural RED `f85408e777480810656d8e161934df8611f4035a`** added empty generic-host and host-triple nested `links` fixtures. The pre-repair classifier accepted both because recursion reached an empty mapping with no scalar payload. +- **Minimal canonical repair `7a72d830dd8e8af8859d676f84e711455e490702`** adds exactly one condition in `_configured_host_execution_authority()`: a non-root empty host mapping is classified as `links build-script override`. Existing typed host-triple controls remain unchanged, non-empty generic/triple hostile cases retain their prior path, and no second Cargo topology/config scanner is introduced. + +## Alternatives considered + +1. **Ignore `[host]` until `-Zhost-config` appears in repository configuration.** Rejected. Invocation flags and Cargo's unstable-feature activation are separate mutable execution surfaces; Git-owned latent authority must not become pre-authorized merely because the current invocation does not activate it. +2. **Reject every `[host]` table or every host rustflag.** Rejected. This conflates deterministic optimization/documentation settings with execution/input authority and would make the guard broader than the owned invariant. +3. **Guess whether every first-level `[host.]` is a host tuple from spelling.** Rejected. Cargo chooses the actual host-triple prefix at runtime and otherwise parses generic `[host]`; tuple-string heuristics would create a second, drifting parser and previously caused false-positive tension. +4. **Treat an empty `links` table as harmless because it contains no replacement fields.** Rejected. Cargo inserts `BuildOutput::default()` for the library name and skips the package build script. Suppression is itself authority. +5. **Create a second Cargo scanner in the focused test.** Rejected. The focused test only constructs hostile/control fixtures and delegates the decision to the canonical compiler-authority owner. + +## Invariants + +- `test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. +- `test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for repository-selected compiler/rustdoc/toolchain/linker execution and external-input authority. +- `[host]` linker/runner authority is fail closed. +- Authority-extending host `rustflags` and `rustdocflags` are fail closed using the same classifier semantics as `[build]` / `[target]`. +- Non-empty and empty host `links` override tables are fail closed because either form can suppress a matching build script; replacement-output fields are not required for that authority to exist. +- Typed non-authority host flags remain admissible controls; the guard is not a blanket ban on host configuration. + +## Remaining evidence and risk + +This generation is source-structural evidence until the exact pull-request head receives executable hosted repository/security checks. It does not claim protected-main integration, release readiness, whole-PR review closure, or owned 100% Docstring/rustdoc/Test/Edge Case Coverage. Ambient user/global Cargo configuration, CLI `--config`, environment variables, and toolchain selection are separate invocation/runtime provenance surfaces and are not made trustworthy by this repository-owned config guard. Those surfaces belong in the CI/release execution-environment contract rather than by extending this Git-source topology scanner. + +## Primary references + +Cargo Team. (2026). *Unstable Features: target-applies-to-host and host-config*. The Cargo Book, nightly documentation. https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#host-config + +Cargo Team. (2026). *TargetConfig*. Cargo 1.100.0-nightly rustdoc. https://doc.rust-lang.org/nightly/nightly-rustc/cargo/context/target/struct.TargetConfig.html + +Cargo Team. (2026). *target.rs*. Cargo source for `load_host_triple`, `load_config_table`, and `parse_links_overrides`. https://doc.rust-lang.org/nightly/nightly-rustc/src/cargo/util/context/target.rs.html + +Cargo Team. (2026). *Configuration*. The Cargo Book. https://doc.rust-lang.org/nightly/cargo/reference/config.html diff --git a/docs/traceability/browser-session-cargo-include-authority.md b/docs/traceability/browser-session-cargo-include-authority.md new file mode 100644 index 000000000..840cfdaeb --- /dev/null +++ b/docs/traceability/browser-session-cargo-include-authority.md @@ -0,0 +1,29 @@ +# Browser Session Cargo include authority + +## Problem + +Cargo configuration can load additional TOML configuration through the top-level `include` key. Cargo resolves include paths relative to the including configuration file, accepts path strings and inline tables, and recursively processes includes before merging the including file on top. A checked-in `.cargo/config.toml` can therefore delegate compiler, rustdoc, linker, environment, target, or build-script-override authority to another repository file that is not itself named `.cargo/config.toml` or `.cargo/config`. + +The Browser Session Cargo compiler-authority owner discovered Git-owned `.cargo/config*` files and classified their parsed keys, but it did not model `include`. An included repository TOML file could consequently carry `[env]`, compiler/linker selectors, rustflags/rustdocflags, or `target..` metadata outside the reviewed config closure. + +## Authority and constraint + +`tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production package/source topology. `tests/test_browser_session_cargo_compiler_authority_contract.py` owns Git-selected Cargo compiler/input configuration authority. The focused include contract imports that owner and does not add another workspace or Cargo-config discovery implementation. + +Until OriginWeave has a recursive, containment-checked, cycle-safe, versioned include graph whose effective merged values are reviewed under the same authority rules, any repository-owned Cargo top-level `include` is fail-closed. This is intentionally narrower and safer than partially following includes while missing precedence, recursion, optional entries, or path semantics. + +## RED → repair evidence + +RED `c8829bfbe5af0bf44f5531189576ff2f5fce7ab1` adds `tests/test_browser_session_cargo_include_authority_contract.py`. It supplies a real repository-relative included TOML file containing an unreviewed `[env]` value and exercises Cargo's path-string, inline-table, and optional-inline-table include forms. A config with no include remains the control. + +Repair `0472a50840876fc80cf431d2d66085566f06b335` minimally extends the existing parsed-config owner. Presence of the top-level `include` key is recorded as unmodeled Cargo execution/input authority and rejected through the same Browser Session provenance error path. No recursive include parser or second config scanner is introduced. + +## Residual execution provenance + +This source contract does not observe command-line `cargo --config`, configuration inherited from ancestor directories or `$CARGO_HOME`, environment-variable overrides, runner images, toolchain installation state, or files outside the Git-owned repository closure. Those remain CI/release/runtime provenance surfaces. If repository Cargo includes are later required, acceptance must prove the complete recursive include graph, path containment, optional-file semantics, merge precedence, cycle behavior, and the effective compiler/input authority after merging. + +## Primary reference + +- The Rust Project. (2026). *The Cargo Book: Configuration — Including extra configuration files*. https://doc.rust-lang.org/cargo/reference/config.html#include + +Cargo documents that top-level `include` loads additional `.toml` files, supports path strings and inline tables with `optional`, recursively processes nested includes, and merges the including file after its included files. That behavior makes the include graph part of exact build provenance rather than a formatting convenience. diff --git a/docs/traceability/browser-session-cargo-links-override-authority.md b/docs/traceability/browser-session-cargo-links-override-authority.md new file mode 100644 index 000000000..e67eec79c --- /dev/null +++ b/docs/traceability/browser-session-cargo-links-override-authority.md @@ -0,0 +1,32 @@ +# Browser Session Cargo links-override authority + +## Problem + +Cargo target configuration can replace the output of a dependency build script when that dependency declares a `package.links` value. A `[target..]` table prevents the build script from running and supplies its metadata directly. Cargo documents override keys including `rustc-link-lib`, `rustc-link-search`, `rustc-flags`, `rustc-cfg`, `rustc-env`, and `rustc-cdylib-link-arg`. + +That table is compiler and native-input authority. It can inject `-l`/`-L` inputs, conditional-compilation values, compile-time environment, and cdylib linker arguments while bypassing the production build-script boundary already reviewed by OriginWeave. The predecessor Cargo compiler-authority scanner inspected `target.` linker, runner, rustflags, and rustdocflags but ignored nested target tables, allowing this build-script replacement path to remain outside exact-tree provenance. + +## Authority and constraint + +`tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source and build-script topology. `tests/test_browser_session_cargo_compiler_authority_contract.py` owns repository-selected compiler/linker/rustdoc input and execution authority. The links-override contract consumes that owner and does not re-scan workspace topology. + +Until a versioned, dependency-specific override contract proves the exact `package.links` owner, native artifacts, search paths, cfg/env values, linker arguments, and replacement semantics, any nested Git-owned `target..` table is fail-closed. Ordinary target tables without a nested links override remain governed by the existing linker/runner/rustflags/rustdocflags rules. + +## RED → repair evidence + +RED `2f0cbdb69033e2d2f2e49393a5eef6b5a9bf6125` adds `tests/test_browser_session_cargo_links_override_contract.py`. Hostile fixtures cover `rustc-link-lib`, `rustc-link-search`, `rustc-cfg`, `rustc-env`, and `rustc-cdylib-link-arg`; a normal target `rustflags` table remains an allowed control when it does not widen the existing authority classifier. + +Repair `a0b85bf55a7736ea99cad1c56c53b7df583647cd` minimally extends the existing parsed target-settings owner. Nested target tables are recorded as links build-script overrides and fail through the same Browser Session provenance error path. No second Cargo configuration or production-topology scanner is introduced. + +## Security and reproducibility effect + +A checked-in Cargo config can no longer replace a linked dependency's build-script outputs with unreviewed native-library/search-path, cfg, environment, or cdylib-linker metadata. This closes a direct bypass around both the build-script boundary and the previously modeled rustc/linker external-input surfaces. + +This source contract does not prove ambient Cargo configuration, command-line `--config`, `$CARGO_HOME`, ancestor configuration, runner/toolchain state, or the contents of future explicitly approved native artifacts. Those remain execution/release provenance surfaces. + +## Primary references + +- The Rust Project. (2026). *The Cargo Book: Configuration — `target..`*. https://doc.rust-lang.org/cargo/reference/config.html#targettriplelinks +- The Rust Project. (2026). *The Cargo Book: Build Scripts — Overriding Build Scripts*. https://doc.rust-lang.org/cargo/reference/build-scripts.html#overriding-build-scripts + +Cargo documents that a target links sub-table prevents the linked package's build script from running and substitutes the listed metadata. It also documents that `rustc-link-lib` maps to rustc `-l`, `rustc-link-search` maps to `-L`, `rustc-cfg` controls compile-time cfg, and the other override keys replace build-script-produced compiler/linker metadata. diff --git a/docs/traceability/browser-session-cargo-target-source-coverage.md b/docs/traceability/browser-session-cargo-target-source-coverage.md new file mode 100644 index 000000000..eac3e8d49 --- /dev/null +++ b/docs/traceability/browser-session-cargo-target-source-coverage.md @@ -0,0 +1,48 @@ +# Browser Session Cargo target-source coverage + +- **Status:** active-PR repository-security evidence for PR #317; not protected-main behavior +- **Owner:** OriginWeave Browser Session bounded context +- **Related authority:** `docs/traceability/browser-session-trusted-adapter-boundary.md`, `docs/THREAT_MODEL.md`, ADR 0114 + +## Problem + +The trusted-adapter repository contract already derives its package review surface from Cargo workspace membership and recursive in-repository production `path` dependencies. Its Rust source scan, however, still treated `src/**/*.rs` as exhaustive. Cargo does not require production library and binary targets to live under `src/`: `[lib].path` and `[[bin]].path` may point at other files relative to the package manifest. + +That difference matters after a production crate becomes an approved Browser Session dependency. A later change could place another `DisposableContextPort` reference in a custom production target outside `src/`; the manifest would remain on the already-reviewed dependency allowlist while the file-level lifecycle-SPI allowlist would never see the new source. The crate-level dependency gate is therefore necessary but not sufficient for exact source-surface review. + +## Authoritative standard + +The Cargo Book, **Cargo Targets**, states that Cargo packages consist of targets corresponding to source files, that target configuration is controlled by `[lib]`, `[[bin]]`, `[[example]]`, `[[test]]`, and `[[bench]]`, and that the `path` field specifies a target source file relative to `Cargo.toml`. Library targets default to `src/lib.rs`, while configured targets may use non-standard paths. OriginWeave treats library and binary targets as the shipped production source surface for this Browser Session composition contract; examples, integration tests, and benches do not grant shipped runtime adapter authority. + +Primary source: Rust Project Developers. (2026). *Cargo Targets*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/cargo-targets.html + +## RED and repair + +- **Structural RED `40c9fb3b452d50fdb1b688e5e7634a1b3858c5e4`** adds `tests/test_browser_session_custom_target_source_contract.py`. Its hostile package declares `[lib] path = "runtime/lifecycle_adapter.rs"` and `[[bin]] path = "command/adapter_cli.rs"`; both files reference `DisposableContextPort` and intentionally live outside `src/`. The prior `_production_sources` helper could not discover either file. +- **Minimal causal repair `b66bb5cd05999f569460c76e173bcf1fd44a2499`** extends the existing production-source closure with explicitly configured library and binary target paths from each reviewed production manifest. Declared paths are resolved relative to their package manifest, must remain inside the repository review root, and must identify an existing file. The existing `src/**/*.rs` scan remains as conservative coverage for default and auto-discovered production sources. + +No Rust runtime, browser-policy, WebDriver BiDi, navigation, or lifecycle semantics changed. This is a repository-security contract repair that makes the existing TCB review policy match Cargo's actual production-target topology. + +## Invariant + +For every production package in the Browser Session trusted-composition closure: + +1. the package manifest is reviewed if it links `originweave-browser-session`; +2. ordinary `src/**/*.rs` production sources remain inside lifecycle-SPI review; +3. every explicitly configured `[lib].path` and `[[bin]].path` is also inside lifecycle-SPI review even when it lives outside `src/`; +4. a configured production target path outside the repository review root fails closed; +5. a configured production target path naming a missing file fails closed; and +6. source and dependency allowlists continue to describe only surfaces present on the same exact tree. + +A future #316 BiDi lifecycle adapter therefore cannot use a custom Cargo target path to widen the Browser Session TCB after its crate dependency has already been approved. Adapter source, Browser Session dependency, target topology, and allowlist widening must remain one reviewed exact-tree delta. + +## Rejected alternatives + +- **Treat `src/**/*.rs` as the production source boundary:** rejected because Cargo target `path` is authoritative and may point elsewhere. +- **Rely only on the crate dependency allowlist:** rejected because it admits a crate, not every future source file or target added inside that crate. +- **Scan every `.rs` file in the repository:** rejected because tests/examples/tooling are different authority surfaces and would collapse production composition with non-shipped code rather than model Cargo targets. +- **Move the Browser Session trust decision into the BiDi adapter:** rejected because deterministic Browser Session composition policy remains owned by Browser Session; protocol adapters consume that contract. + +## Evidence state + +The two commits above are structural/source-contract evidence on Draft PR #317. Draft policy does not provide executable exact-head repository/security GREEN. Parent #229 remains the executable prerequisite, and #317 still requires authorized ordinary/non-force ancestry reconciliation followed by fresh exact-head checks and review before this contract can be treated as merge evidence. diff --git a/docs/traceability/browser-session-codegen-backend-authority.md b/docs/traceability/browser-session-codegen-backend-authority.md new file mode 100644 index 000000000..9a8c6a3a3 --- /dev/null +++ b/docs/traceability/browser-session-codegen-backend-authority.md @@ -0,0 +1,47 @@ +# Browser Session code generation backend authority + +## Problem + +OriginWeave's Browser Session trust boundary reviews repository-owned Cargo package/source topology and Git-owned compiler/linker/toolchain execution inputs. Rust and Cargo also expose an unstable code generation backend selection surface. A repository can select a Cargo profile `codegen-backend`, or pass rustc `-Zcodegen-backend=` through Cargo `rustflags`. + +This is execution provenance, not an optimization-only preference. rustc's unstable `codegen-backend` flag accepts a path to a dynamic library and loads that library as the code generation backend at runtime. Cargo's unstable `codegen-backend` feature permits profile-level backend selection, including from root `Cargo.toml` and Cargo configuration. Leaving those surfaces outside the canonical compiler-authority contract would let Git-owned configuration replace code-generation implementation without changing the reviewed production Rust source closure. + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology and dependency-source containment. +- `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the owner for Git-owned Cargo/rustc execution and compiler-input authority. +- Cargo profile settings are inspected only for `codegen-backend`; ordinary optimization/debug profile settings remain valid. +- This slice does not attest ambient `RUSTFLAGS`, command-line `cargo -Z codegen-backend`, the installed rustc sysroot/codegen backend artifacts, or runner/container images. + +## Authoritative evidence + +The Cargo Book's current unstable-features reference states that `codegen-backend` selects the backend used by rustc through a profile. It shows `[profile.dev.package.foo] codegen-backend = "cranelift"` and states that profile configuration requires either `-Z codegen-backend` or `[unstable] codegen-backend = true`. The Cargo profiles reference states that profile settings in the root workspace manifest are authoritative and may be overridden by Cargo configuration. + +The Rust Unstable Book states that `-Zcodegen-backend=` selects a dynamic library used as rustc's code generation backend at runtime and requires that library to expose `__rustc_codegen_backend`. + +Primary references: + +- Cargo Book, *Unstable Features — codegen-backend*: https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#codegen-backend +- Cargo Book, *Profiles*: https://doc.rust-lang.org/nightly/cargo/reference/profiles.html +- Rust Unstable Book, *codegen-backend*: https://doc.rust-lang.org/nightly/unstable-book/compiler-flags/codegen-backend.html + +## RED → repair + +RED `85954d363f78621d3f9dc1dc0ad0ef304b892fae` adds focused hostile fixtures for compact and split `-Zcodegen-backend=` in build/target `rustflags`, Cargo config profile selection, and root-manifest profile selection. An ordinary `opt-level` profile setting remains an allowed control. + +Repair `ff7d48bc875b3d68486e7e8265af44dbb6c4b1ed` extends the existing compiler-authority owner rather than adding a second Cargo scanner. It: + +- treats active `[unstable] codegen-backend` as compiler/toolchain execution authority; +- recognizes compact and split rustc `-Zcodegen-backend` in Git-owned build/target `rustflags`; +- rejects `codegen-backend` keys in root-workspace Cargo profiles and Cargo-config profile overrides; +- leaves unrelated unstable settings and ordinary profile optimization settings alone. + +## Decision and security effect + +Repository-owned Browser Session build configuration may not replace rustc's code generation backend until the selected backend is explicitly versioned, integrity-bound, reproducibly obtained, and covered by the same compiler/toolchain provenance and release evidence as the Rust toolchain itself. A future approved backend must identify the exact rustc/Cargo toolchain, backend artifact or rustup component, artifact digest/signature/provenance, supported target matrix, fallback behavior, reproducibility evidence, and removal/rollback path before this fail-closed rule is relaxed. + +## Residual execution/release provenance + +This source contract does not prove ambient command-line or runner state. Remaining surfaces include direct `cargo -Z codegen-backend`, ambient `RUSTFLAGS`/`CARGO_ENCODED_RUSTFLAGS`, ancestor or `$CARGO_HOME` configuration, rustup component installation, sysroot-provided backends, custom target/toolchain composition, and runner/container image provenance. These belong to executable CI/release evidence unless a repository-owned surface begins selecting them, in which case another focused contract is required. + +No hosted repository execution, protected-head GREEN, immutable release, or browser-observed acceptance is claimed by this source-semantic repair alone. diff --git a/docs/traceability/browser-session-custom-target-mod-lexical-authority.md b/docs/traceability/browser-session-custom-target-mod-lexical-authority.md new file mode 100644 index 000000000..ba5a10847 --- /dev/null +++ b/docs/traceability/browser-session-custom-target-mod-lexical-authority.md @@ -0,0 +1,68 @@ +# Browser Session custom-target `mod` lexical authority + +Status: active PR evidence only. This document does not claim protected-main, hosted-check, or release acceptance. + +## Problem + +`tests/test_browser_session_rust_source_indirection_contract.py` intentionally fails closed when a reviewed Cargo production target whose crate root is outside the package's default `src/` tree contains a Rust `mod` token. That guard prevents a custom target such as `runtime/lifecycle_adapter.rs` from loading an outlined sibling module that is not already covered by the canonical `src/**/*.rs` production-source closure. + +The predecessor implementation used `CUSTOM_TARGET_MOD_TOKEN.search(text)` over raw source bytes. The policy was conservative, but the implementation also treated `mod ...` text inside non-doc comments and ordinary/raw string literals as executable module authority. That is a lexical false positive: Rust non-doc comments are interpreted as whitespace, and string/raw-string literals are tokens whose contents are data rather than item grammar. + +The first lexical repair exposed a second, independent language-boundary defect. Python regular-expression `\w` is not Rust's identifier grammar. Rust identifiers use Unicode `XID_Start`/`XID_Continue` from Unicode 17.0, so U+0301 COMBINING ACUTE ACCENT can continue an identifier even though Python's `\w` boundary does not treat it as a word character. Consequently `mod\u0301` is one identifier token, not the strict `mod` keyword, and a Python-`\w` keyword boundary can reject source that has no lexical `mod` token. + +The same audit found that Rust's lexical whitespace is the stable Unicode `Pattern_White_Space` set, not Python `str.isspace()`. In particular U+200E LEFT-TO-RIGHT MARK and U+200F RIGHT-TO-LEFT MARK are legal Rust whitespace. Failing to skip them between `include`, `!`, and the macro delimiter creates a false negative in the existing source-provenance stop. + +A later residual audit found the same host-language boundary still present in the aliased-`include!` path: `USE_TOKEN` and `AS_TOKEN` used Python `\w`. A valid Rust identifier can contain U+0301 immediately before the ASCII spelling `use`; inside a macro token tree, that spelling is identifier data, not a `UseDeclaration`. The old scanner could nevertheless start a synthetic use-tree at that substring and then mistake later `core::include as hidden_include` token data for executable source-indirection authority. This was an availability false positive in a fail-closed security boundary, not a reason to weaken the source-provenance stop. + +This matters commercially because a fail-closed provenance guard still has to distinguish executable authority from inert source text without missing legal Rust token separation. False positives create avoidable adoption friction; false negatives permit compile-time source bytes to enter outside the reviewed provenance boundary. + +Primary references: + +- Rust Reference, identifiers (`XID_Start`/`XID_Continue`, Unicode 17.0): https://doc.rust-lang.org/reference/identifiers.html +- Rust Reference, use declarations (`use UseTree ;`, `as ( IDENTIFIER | _ )`): https://doc.rust-lang.org/reference/items/use-declarations.html +- Rust Reference, whitespace (`Pattern_White_Space`): https://doc.rust-lang.org/reference/whitespace.html +- Rust Reference, comments: https://doc.rust-lang.org/reference/comments.html +- Rust Reference, literal expressions: https://doc.rust-lang.org/reference/expressions/literal-expr.html +- Rust Reference, modules and module source filenames: https://doc.rust-lang.org/reference/items/modules.html + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py::_workspace_production_sources()` remains the single writer for Cargo production package/source topology. +- The custom-target rule remains intentionally conservative for *real lexical* `mod` tokens outside default `src/`: it still does not attempt to distinguish inline from outlined modules or reproduce the Rust parser. +- The scanner must reuse the existing Rust trivia/raw-string/quoted-string/character-literal discipline already used by `include!`, `use`-alias, and attribute discovery rather than introduce a second lexer. +- Python's Unicode database must not silently define Rust keyword identity. The checked Rust Reference currently targets Unicode 17.0, so the boundary cannot assume Python `\w` or the local Python runtime's identifier tables are equivalent. +- Rust whitespace handling must use the language's exact stable `Pattern_White_Space` set. Generic host-language whitespace predicates are not lexical authority. +- `use` and `as` keyword recognition must reuse the same `_rust_identifier_token_end()` boundary as `include`, `path`, and `mod`; raw-identifier acceptance remains spelling-specific and is not enabled for strict keywords. +- No new source path, module tree, adapter, or dependency is authorized. +- Default `src/` module trees remain governed by the canonical production-source closure rather than this custom-target guard. + +## Decision + +Custom-target module detection advances through the same lexical boundaries already used by the source-indirection contract. Non-doc line/block comments, normal strings, raw strings, and character literals are skipped before the `mod` spelling is considered. A real lexical `mod` token still fails closed exactly as before; only inert comment/literal contents stop being treated as module authority. + +The `mod` keyword boundary no longer relies on Python `\w`. ASCII identifier continuation is handled directly. For non-ASCII adjacency the guard is deliberately conservative: any non-ASCII scalar that is not Rust `Pattern_White_Space` prevents classification as the ASCII keyword. This covers current and future Unicode identifier-continuation additions without pretending the host Python Unicode table is Rust's versioned `XID_Continue` authority. Rust's eleven `Pattern_White_Space` code points are explicit and stable, so non-ASCII legal whitespace such as U+200E continues to separate a real keyword. + +The shared trivia skipper uses that exact Rust whitespace set as well. This closes legal U+200E/U+200F separation around `include!` and removes host-only whitespace from the lexer contract. Comment handling remains nested and unchanged. + +Aliased-`include!` discovery now recognizes strict `use` and `as` through `_rust_identifier_token_end()` rather than Python regexes. The valid-use hostile control remains fail closed, while an ASCII `use` substring adjacent to Rust Unicode identifier continuation is treated as identifier data. `include` retains its explicit raw-identifier support; `use` and `as` do not, matching their role as strict grammar keywords rather than imported identifier spellings. + +The repair deliberately does not parse module or use-tree grammar beyond the existing provenance heuristic. `mod helper;`, `mod r#type;`, `mod 관찰;`, `mod /* trivia */ helper;`, and a real `use core::include as hidden_include;` remain provenance stops. The changes only remove raw-text/identifier-boundary false positives and close Rust-whitespace false negatives. + +## RED → repair evidence + +- Predecessor exact `84fb37d31fbb5f1145b78a700ce77771319a032b` used raw `CUSTOM_TARGET_MOD_TOKEN.search(text)` for custom-target roots. +- Structural RED `bd457be344c729d550e301a403e77bb5959e5b28` adds line-comment, ordinary-string, and raw-string controls. On the predecessor implementation these fixtures are rejected even though the `mod` spelling is lexical data. +- Minimal repair `6f8b0706acaf5837e3581f1c77d43c318a54462c` adds `_has_custom_target_mod_token()` and changes the custom-target guard to consume it. The helper reuses `_skip_rust_trivia()`, `_raw_string_end()`, `_quoted_string_end()`, and `_simple_char_literal_end()`; Cargo topology ownership is unchanged. +- Edge coverage `a4ad6d45d101b79460fbc06ca9ede9b1c3b0b140` adds nested-block-comment lexical data and proves scanning resumes after a character literal to catch a later real `mod` token. +- Focused CodeRabbit review of `c36f8630cfdc9887a3fda8716c3cccbc0ae370b6` found a valid remaining identifier-boundary false positive: Python `\w` does not model Rust Unicode 17.0 `XID_Continue`, so `mod\u0301` was incorrectly classified as the strict keyword. +- Review-driven RED `a214c87d375e75a9c10edc3c6de99b4847c43327` preserves `mod\u0301` as identifier data. Repair `ec32bd9f481280b522ce7554d392021b40c7fea1` replaces the Python-regex keyword boundary with a version-independent conservative Rust boundary backed by the exact stable `Pattern_White_Space` set. +- Edge coverage `df940ba25b2c7731d6fe631290f000ce1d57bcd0` adds a combining-mark-before-`mod` control and proves U+200E Rust whitespace still separates a real `mod` keyword. +- Root-cause audit then exposed a real false negative in the shared trivia owner: Python `str.isspace()` does not recognize U+200E/U+200F even though Rust does. Structural RED `ce33ae1aa020b1b9903aa02c5952a90bfd1581e5` adds hostile `include\u200e!` and `include!\u200f(` forms. Repair `e199aac4a64e636b3a7412f3d9347644e96e636b` makes `_skip_rust_trivia()` consume the exact Rust `Pattern_White_Space` set. +- Structural RED `550d8bf2f00d59b08a13d7a3efa80acbb3614daf` adds a valid macro-token control containing `a\u0301use core::include as hidden_include`; on the predecessor scanner the Python `\w` boundary started a false `UseDeclaration` at the embedded `use` spelling. The same contract keeps a real `use core::include as hidden_include;` as a fail-closed positive control. +- Minimal repair `1f323bca9494aa3e16d5f3daaf27a5b21277a9fb` removes the remaining `re`/`USE_TOKEN`/`AS_TOKEN`/stale `PATH_TOKEN` regex ownership and reuses `_rust_identifier_token_end()` for strict `use` and `as`. The repair changes only the source-indirection contract (+7/-11); Cargo topology ownership and production Rust code are unchanged. + +## Risk and follow-up + +This remains a temporary lexical security boundary. It is not compiler-derived source-input provenance and can intentionally reject legitimate inline modules in custom target roots. Before OriginWeave needs such custom-target module trees, replace the heuristic with compiler-derived or equivalently exact source-input evidence that identifies the actual bytes compiled for supported target configurations without widening Cargo ownership or relying on source-text approximations. + +The identifier-boundary root-cause audit is now single-owner for the covered ASCII spellings: `include`, `path`, `mod`, `use`, and `as` all consume `_rust_identifier_token_end()` rather than Python regex keyword boundaries. Future source-indirection syntax must receive its own structural hostile/control fixture before widening this lexical owner. The broader `docs/traceability/browser-session-rust-source-indirection.md` remains the canonical source-indirection record and should absorb this focused history when the current stacked Browser Session lineage is reconciled. diff --git a/docs/traceability/browser-session-custom-target-spec-authority.md b/docs/traceability/browser-session-custom-target-spec-authority.md new file mode 100644 index 000000000..3fa4a2313 --- /dev/null +++ b/docs/traceability/browser-session-custom-target-spec-authority.md @@ -0,0 +1,53 @@ +# Browser Session custom target specification authority + +Status: Draft source-semantic contract evidence on PR #317. This document does not claim hosted executable, repository-security, or browser GREEN. + +## Problem + +Cargo `build.target` accepts a built-in rustc target, `host-tuple`, or a path to a custom target specification. A repository-owned `.cargo/config.toml` or legacy `.cargo/config` can therefore select a JSON target specification without changing Cargo package/source topology or the visible `rustflags`/`rustdocflags` already covered by the Browser Session compiler-authority contract. + +Current nightly Cargo gates custom target JSON use behind `-Z json-target-spec`; Cargo also documents that `-Z` features can be enabled through the config `[unstable]` table. The realistic repository-owned path is therefore `[unstable] json-target-spec = true` together with `[build].target = "path/to/spec.json"`. The gate alone does not select an artifact, so the contract fails closed on the actual custom target selection rather than banning the feature flag globally. + +That selection is provenance-bearing. Rust custom target specifications describe compiler target behavior rather than merely naming an output directory. Current rustc target metadata exposes linker selection, linker flavor, pre/post link objects, pre/late/post link arguments, link scripts, linker environment changes, and assembler arguments among the target options. A Git-owned custom target can therefore alter native tool execution or native/link inputs while the reviewed Rust source closure is unchanged. + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology and dependency-source discovery. +- `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the owner for repository-selected compiler, rustdoc, linker, toolchain, and external-input authority. +- Built-in target triples and Cargo's `host-tuple` remain allowed. The repair is not a blanket `build.target` or `json-target-spec` ban. +- This contract does not authorize a custom target JSON artifact. An approved future target specification needs immutable artifact identity, compiler-version/schema pinning, transitive linker/native-input provenance, SBOM/attestation, rollback, and the same-tree executable evidence. +- `CARGO_BUILD_TARGET`, direct Cargo `--target`, `RUST_TARGET_PATH`, and target specifications resolved from a rustc sysroot are execution-environment or toolchain inputs and remain with the CI/release supply-chain owner. + +## RED + +Commit `9a7477e6e19d739cead5c90fa63070aa85a01cf6` adds `tests/test_browser_session_custom_target_spec_authority_contract.py`. The hostile fixture selects `targets/review-bypass.json` through build-level `target`; the target JSON names a different linker. A second fixture places the JSON path beside a built-in target in Cargo's array form. The predecessor compiler-authority contract did not classify `build.target`, so both repository-owned custom-target selectors were outside its fail-closed surface. Built-in target and `host-tuple` controls are retained. + +Commit `6f2ee50a6a461ebd55d7a23c5435a111faf6a792` aligns that focused fixture with current Cargo behavior by enabling `[unstable] json-target-spec = true` in the repository config. The same gate is present in the built-in-target controls, proving that the repair keys on artifact selection rather than the feature switch itself. + +## Decision and repair + +Commit `8e4db1b2229a6b77d117be8ed2d0595bbd96a1d7` minimally extends the existing Cargo compiler-authority owner with `_configured_custom_target_specs`. Build-level target strings or arrays whose entries end in `.json` are recorded as `target:custom target specification:` and fail through the existing execution-override assertion. Built-in target triples and `host-tuple` do not enter that list. + +No second Cargo topology scanner was added. The hostile contract imports and exercises the canonical compiler-authority assertion, while package/source discovery remains delegated to the trusted-adapter boundary. + +## Security effect and residual risk + +The repair closes Git-owned Cargo configuration that directly selects a JSON rustc target specification through `[build].target`. It prevents an unreviewed target JSON from changing linker/native-input behavior behind an otherwise unchanged Browser Session source/dependency closure. + +It does not prove ambient target selection trustworthy. Environment `CARGO_BUILD_TARGET`, direct `cargo ... --target`, `RUST_TARGET_PATH`, sysroot target metadata, runner-installed compiler versions, or schema compatibility remain CI/release supply-chain concerns. Rust documents custom target JSON properties as unstable and recommends pinning the compiler version; any future approved target JSON must therefore be versioned and evidenced together with the exact rustc/toolchain that consumes it. + +## Acceptance + +The source contract must remain Draft until the reconciled exact #317 head receives hosted repository/security execution and independent current-head review. Parent #229 ancestry and required checks remain prerequisites; source-level RED→repair here does not transfer predecessor executable evidence. + +## References + +The Cargo Project. (n.d.). *Configuration*. *The Cargo Book*. Retrieved September 18, 2026, from https://doc.rust-lang.org/cargo/reference/config.html + +The Cargo Project. (n.d.). *Unstable features*. *The Cargo Book*. Retrieved September 18, 2026, from https://doc.rust-lang.org/nightly/cargo/reference/unstable.html + +The Rust Project Developers. (n.d.). *Custom targets*. *The rustc book*. Retrieved September 18, 2026, from https://doc.rust-lang.org/rustc/targets/custom.html + +The Rust Project Developers. (2026). *rustc_target::spec* (rustc 1.100.0-nightly, 330d31712 2026-09-17). Retrieved September 18, 2026, from https://doc.rust-lang.org/nightly/nightly-rustc/rustc_target/spec/ + +The Rust Project Developers. (n.d.). *TargetOptions*. *rustc_target::spec*. Retrieved September 18, 2026, from https://doc.rust-lang.org/beta/nightly-rustc/rustc_target/spec/struct.TargetOptions.html diff --git a/docs/traceability/browser-session-external-crate-input-authority.md b/docs/traceability/browser-session-external-crate-input-authority.md new file mode 100644 index 000000000..b1bd7b9b9 --- /dev/null +++ b/docs/traceability/browser-session-external-crate-input-authority.md @@ -0,0 +1,58 @@ +# Browser Session external-crate input authority + +## Problem + +The Browser Session repository contract already derives production Cargo package/source topology from `tests/test_browser_session_trusted_adapter_boundary.py` and constrains repository-owned Cargo compiler/linker execution plus native-library input expansion. That boundary did not constrain rustc `--extern` supplied through Git-owned Cargo `rustflags`. + +rustc documents `--extern` as specifying the name and optional location of a direct external crate. `--extern CRATENAME=PATH` names an exact precompiled crate artifact, while pathless `--extern CRATENAME` makes the crate a candidate from rustc's external-library search path. The crate name is also added to the extern prelude. Repository-owned Cargo configuration could therefore add a precompiled Rust dependency outside the reviewed production source/dependency closure without modifying `Cargo.toml`, the canonical Cargo topology, or the nominal compiler/linker selection. + +This is a provenance gap even when the injected crate is not ultimately linked: the compiler is allowed to resolve and expose an additional direct dependency candidate whose producer, source tree, digest, feature set, target, and build evidence are not represented by the exact reviewed tree. + +## Constraint + +`tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. The Cargo compiler-authority contract may consume that topology and constrain repository-owned rustc input authority, but it must not create a second workspace/package/dependency resolver. + +This slice applies only to Git-owned Cargo `rustflags` in `.cargo/config.toml` and `.cargo/config`. Environment `RUSTFLAGS` / `CARGO_ENCODED_RUSTFLAGS`, direct `cargo rustc -- ...` trailing flags, rustup/sysroot/toolchain composition, and external build-system injection remain separate owner surfaces. + +## RED → repair + +RED `dbd9faa623f01652c2e75904af8bec614c2e6fc9` adds hostile contract cases for: + +- split `--extern review_bypass=tools/libreview_bypass.rlib` in `[build].rustflags`; +- equals-form `--extern=review_bypass=tools/libreview_bypass.so` in target-scoped `rustflags`; and +- pathless `--extern review_bypass`, which can resolve from the external-library search path. + +The predecessor exact `a370bad3ce3f56b9ba7f0ff0ded589e97608dcdf` allowed all three forms because external-input classification covered `-L` / `-l` and linker-forwarded native inputs but not rustc external-crate injection. + +Repair `098a596029c0cf339c070604a265593540822956` keeps production topology ownership unchanged and introduces one rustc-level external-input classifier. It delegates native-library/search-path forms to the existing linker-input classifier and additionally fails closed on `--extern` and `--extern=...`. The linker-driver parser remains unchanged because `--extern` is rustc input authority rather than a linker-driver option. An unrelated `--check-cfg` control remains allowed. + +## Decision + +Until precompiled external-crate provenance is modeled as a versioned reviewed artifact contract, repository-owned Cargo configuration must not use rustc `--extern` to widen Browser Session production-package dependency inputs outside the canonical Cargo dependency/source closure. + +This is not a claim that `--extern` is unsafe. It is rejected at this boundary because neither a pathname nor a crate name proves the artifact's source, producer, target compatibility, features, digest, reproducibility, or review ancestry. + +A future allowlist must bind at minimum the crate identity, exact artifact digest, producer/source revision, rustc/toolchain identity, target triple, crate type, enabled features/configuration, reproducible-build evidence, and consumer contract on the same reviewed exact tree. Path-only approval is insufficient. + +## Residual surfaces + +The following remain separate review surfaces and are not pre-authorized by this decision: + +- positional object/archive inputs forwarded through `-C link-arg` / `link-args`; +- environment `RUSTFLAGS` / `CARGO_ENCODED_RUSTFLAGS` and direct `cargo rustc` trailing arguments; +- rustup/sysroot/toolchain composition and custom target specifications; +- target-specific external toolchain scripts and non-GNU native control/input mechanisms. + +## Primary evidence + +Rust Project. (2026). *Command-line arguments: `--extern`*. The rustc book. https://doc.rust-lang.org/nightly/rustc/command-line-arguments.html + +The rustc documentation states that `--extern` specifies the name and location of an external crate for a direct dependency. It accepts `CRATENAME=PATH` and pathless `CRATENAME`, adds the name to the extern prelude, and allows multiple external artifacts for the same crate name. + +Rust Project. (2026). *Extern crate declarations*. The Rust Reference. https://doc.rust-lang.org/reference/items/extern-crates.html + +The Rust Reference defines external-crate dependencies and explains that external crates participate in compile-time resolution and linkage semantics. This supports treating precompiled external-crate injection as dependency/input provenance rather than inert compiler metadata. + +## Verification state + +The RED and repair commits are structurally present on the active #317 lineage. This dossier does not promote the branch to executable GREEN. Current-head hosted repository/security workflows and independent current-head review remain required after lineage reconciliation before merge or release readiness can be claimed. diff --git a/docs/traceability/browser-session-host-cpu-codegen-authority.md b/docs/traceability/browser-session-host-cpu-codegen-authority.md new file mode 100644 index 000000000..6d7657b10 --- /dev/null +++ b/docs/traceability/browser-session-host-cpu-codegen-authority.md @@ -0,0 +1,58 @@ +# Browser Session host-CPU codegen authority traceability + +Status: Draft contract evidence on PR #317. This document does not claim hosted executable, repository/security, coverage, release, or runtime GREEN. + +## Problem + +Rust documents `-C target-cpu=` as the compiler control that selects the processor for generated code. The special value `native` means the processor of the host machine. Rust also documents unstable `-Z tune-cpu=` as using the same CPU value set for instruction scheduling; `native` therefore makes the result depend on the machine that happened to run the compiler. + +A repository-owned Cargo configuration can place those options in build/target `rustflags`, profile `rustflags`, build/target `rustdocflags`, or rustdoc `--doctest-build-arg`. In that form the reviewed Git tree no longer determines the code-generation CPU by itself. Two otherwise identical builds may select different instruction sets or scheduling according to runner hardware, which is incompatible with the release contract's reproducibility and exact-provenance requirements. + +The risk is narrower than `target-cpu` in general. An explicit CPU such as `x86-64-v3` is repository-visible and deterministic at this boundary; it still requires normal target/runtime compatibility evidence, but it is not an ambient host selector. This contract therefore fails closed only on `target-cpu=native` and `tune-cpu=native` in Git-owned Cargo flag surfaces. + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. +- `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for Git-owned Cargo compiler/rustdoc/toolchain execution and input authority. +- This contract does not ban explicit fixed `target-cpu` values or ordinary codegen options solely because they tune code generation. +- Ambient `RUSTFLAGS`, `CARGO_ENCODED_RUSTFLAGS`, `RUSTDOCFLAGS`, direct `cargo rustc`/`cargo rustdoc` flags, runner CPU selection, virtualization, and externally selected build images remain CI/release supply-chain evidence rather than leaf repository configuration authority. +- A future exception for host-derived codegen must not be represented as a pathname or string allowlist. It needs an explicit build-hardware identity, target compatibility decision, artifact provenance/SBOM linkage, reproducibility policy, and rollback story. + +## RED + +Commit `cdec934c8d7fb8e14eb17cb0885bf1ba9d6fa277` adds hostile fixtures covering: + +- build `rustflags = ["-C", "target-cpu=native"]`; +- target compact `-Ctarget-cpu=native`; +- profile `--codegen=target-cpu=native`; +- build `rustdocflags` selecting `target-cpu=native`; +- rustdoc `--doctest-build-arg` forwarding `-C target-cpu=native`; +- unstable split `-Z tune-cpu=native`; +- an explicit `target-cpu=x86-64-v3` control that must remain allowed. + +The predecessor classifier had no ambient-host CPU check, so these hostile configurations were not represented by the canonical Cargo authority contract. Because the PR is Draft and no exact-head hosted run is available, this is source-semantic RED evidence rather than an executed hosted RED claim. + +## Decision and repair + +Commit `3dc8702b74f845750cee88e1a34ca27cbbd4d7d2` adds `_flags_select_ambient_host_cpu()` to the existing canonical Cargo compiler-authority contract. It recognizes split, compact, and long codegen forms of `target-cpu=native`, plus split/compact `-Z tune-cpu=native`, without creating a second Cargo configuration scanner. + +The existing build, target, profile-rustflags, rustdocflags, and rustdoc doctest-forwarding paths now reuse that classifier. Policy-specific evidence is emitted as `rustflags:ambient host cpu`, `rustdocflags:ambient host cpu`, or the profile path ending in `:ambient host cpu`; doctest forwarding remains owned by the existing `rustdocflags:doctest compiler authority` marker. + +The fixed-CPU control stays accepted. That preserves intentional cross-build optimization choices while removing the runner-hardware dependency from Git-owned build configuration. + +## Security and commercial effect + +- Exact source review can no longer silently become host-CPU-dependent through repository Cargo flags. +- Reproducibility and artifact-attestation claims cannot be satisfied by two builds that happen to compile for different host CPUs under the same reviewed tree. +- The repair does not claim that CI runner hardware is already attested. It keeps that residual in the CI/release owner where image, runner, CPU, and final artifact provenance can be bound together. +- No WebDriver BiDi/browser domain truth, adapter tuple, navigation authority, recovery state, or protocol identifier moves out of OriginWeave's existing bounded contexts. + +## Evidence status + +The RED and repair are present on the PR #317 lineage. Fresh hosted executable evidence, exact-head full review, Rust/docstring/test/edge coverage, parent #229 CodeQL closure, non-force parent reconciliation, and immutable release evidence remain independent gates. + +## References + +The Rust Project Developers. (2026). *Codegen options: target-cpu*. The rustc book. https://doc.rust-lang.org/rustc/codegen-options/#target-cpu + +The Rust Project Developers. (2026). *Codegen options: tune-cpu*. The rustc book. https://doc.rust-lang.org/rustc/codegen-options/#tune-cpu diff --git a/docs/traceability/browser-session-incremental-cache-input-authority.md b/docs/traceability/browser-session-incremental-cache-input-authority.md new file mode 100644 index 000000000..2eee6777b --- /dev/null +++ b/docs/traceability/browser-session-incremental-cache-input-authority.md @@ -0,0 +1,50 @@ +# Browser Session incremental-cache input authority + +## Decision + +OriginWeave treats an explicit repository-selected rustc `-C incremental=` value as mutable compiler-input authority. Git-owned Cargo `rustflags`, `rustdocflags`, profile `rustflags`, and rustdoc `--doctest-build-arg` forwarding must fail closed when they select an incremental cache directory. + +Cargo's own boolean `build.incremental` / profile `incremental` setting is not rejected by this source contract. Cargo owns its normal target-directory cache placement; the integrity and lifecycle of runner caches, `CARGO_INCREMENTAL`, target directories, toolchain images, and restored CI artifacts remain CI/release supply-chain evidence. + +## Problem + +`rustc -C incremental=` is not only a compile-speed preference. rustc stores compilation information in the selected directory and reuses it on later compilations. Current Cargo source also constructs the compiler invocation by adding `-C incremental=` for Cargo-managed incremental builds. A repository-selected arbitrary path can therefore make the reviewed Browser Session build consume mutable work products whose producer execution, source state, toolchain, lifetime, and digest are not established by the repository source tree. + +A path allowlist is insufficient: a reviewed pathname does not prove the identity or freshness of the cache contents, symlink containment, producer toolchain, or reproducible reconstruction. + +## Contract and causal repair + +The structural RED is commit `f455739da5953f0c70d4289cef795a967a0348c2`, `tests/test_browser_session_incremental_cache_input_authority_contract.py`. It fixes hostile cases for: + +- build `rustflags`: split `-C`, `incremental=`; +- target `rustflags`: compact `-Cincremental=`; +- Cargo profile `rustflags`: `--codegen=incremental=`; +- build `rustdocflags`; and +- rustdoc `--doctest-build-arg` forwarding. + +The same test keeps Cargo-managed `[build] incremental = false` as an allowed control. + +The minimal repair is commit `6753b717486bd025c97043de7a53323c6dd4d47c`. The canonical compiler-authority owner, `tests/test_browser_session_cargo_compiler_authority_contract.py`, extends `_codegen_option_extends_external_inputs()` to classify `incremental=` alongside PGO profile inputs. Existing build/target rustflags, rustdocflags, profile-rustflags, and doctest-forwarding call sites consume the same classifier; no second Cargo topology scanner or downstream policy copy is introduced. + +## Invariants + +1. Repository-owned explicit incremental cache paths cannot become unreviewed compiler input. +2. Cargo's ordinary boolean incremental setting is not conflated with a repository-selected arbitrary cache pathname. +3. Browser Session source authority does not claim to attest ambient runner caches or externally restored target directories. +4. Any future exception must identify the cache content immutably and bind it to producer source, exact toolchain, target, compilation options, SBOM/provenance, expiry/invalidation policy, and reproducible fallback before the fail-closed rule is relaxed. + +## Residual evidence boundary + +This source contract does not prove environment/direct-CLI `RUSTFLAGS` or `CARGO_ENCODED_RUSTFLAGS`, `CARGO_INCREMENTAL`, ancestor or `$CARGO_HOME` configuration, externally restored `target/` contents, runner image state, rustup/sysroot state, or remote build-cache integrity. Those remain canonical CI/release supply-chain responsibilities and must not be represented as closed by this repository-only check. + +## Primary references + +Rust Project. (2026). *Codegen options: incremental*. The rustc book. https://doc.rust-lang.org/rustc/codegen-options/#incremental + +Rust Project. (2026). *cargo::core::compiler: add_codegen_incremental*. Cargo API documentation. https://doc.rust-lang.org/stable/nightly-rustc/cargo/core/compiler/index.html + +Rust Project. (2026). *CodegenOptions*. rustc_session API documentation, rustc 1.100.0-nightly (923c95cdf, 2026-09-16). https://doc.rust-lang.org/nightly/nightly-rustc/rustc_session/options/struct.CodegenOptions.html + +Rust Project. (2026). *Profiles: incremental*. The Cargo Book. https://doc.rust-lang.org/nightly/cargo/reference/profiles.html#incremental + +Accessed 2026-09-18. diff --git a/docs/traceability/browser-session-lifecycle-authority.md b/docs/traceability/browser-session-lifecycle-authority.md index 66336ac88..c77d519e6 100644 --- a/docs/traceability/browser-session-lifecycle-authority.md +++ b/docs/traceability/browser-session-lifecycle-authority.md @@ -2,94 +2,156 @@ - Status: IMPLEMENTED_ON_ACTIVE_PR - Owning bounded context: `originweave-browser-session` -- Governing proposal: ADR 0114 +- Governing proposals: ADR 0114; ADR 0116 - Requirement owner: issue #312 -- Integration prerequisites: #229 presentation-ownership witnesses; canonical browser/sandbox owner path under #212/#148 +- Integration prerequisites: #229 presentation-ownership witnesses; #314/#316 WebDriver BiDi ACL after this foundation is exact-head GREEN ## Problem and invariant -Browser-session, user-context/isolation, and browsing-context identifiers are addresses. They are not evidence that the current Browser Session aggregate exclusively owns presentation mutation or cleanup. A retained authority must not regain meaning if a later aggregate reuses the same remote identifiers and local epoch. +Browser-session, user-context/isolation, browsing-context, adapter-selected identifiers, navigation ids, recovery evidence, and adapter command results are addresses or evidence. None is self-authenticating lifecycle, presentation, navigation, or recovery authority. -The active implementation now establishes this chain: +The active implementation establishes this chain: ```text validated BrowserSessionId → BrowserSession::start allocates non-reused BrowserSessionIncarnation -→ DisposableContextPort receives session id + incarnation -→ adapter creates fresh task-owned isolation boundary + browsing context -→ adapter returns DisposableIsolationId + BrowsingContextId -→ aggregate records exact handle + monotonic context epoch +→ BrowserSession::bind_lifecycle_port consumes one concrete DisposableContextPort +→ BoundBrowserSession

owns aggregate + exact port; no public raw port accessor exists +→ aggregate validates Active + reserves monotonic BrowserContextEpoch +→ aggregate privately constructs DisposableContextCreateRequest(session, incarnation, attempt epoch) +→ exact owned port creates a remote candidate but must keep it non-authorizing +→ aggregate validates returned isolation/context against current ownership +→ aggregate privately constructs DisposableContextCreateCompletion(attempt, Accepted|Rejected) +→ accepted candidate may become adapter-authorizing; rejected candidate remains quarantined +→ uncertain/rejected create paths retain exact attempt and identity facts as non-authorizing recovery evidence +→ aggregate records accepted exact handle + epoch → opaque PresentationMutationAuthority(session, incarnation, isolation, context, epoch) -→ exact authority validation before adapter I/O -→ destruction receives the same incarnation + stored handle -→ adapter proves exact disposable boundary destruction -→ context Destroyed -→ normal BrowserSession::end admitted +→ exact authority validation before ordinary lifecycle or purpose-bounded adapter I/O +→ failed/unproven destruction retains exact handle + epoch and enters RecoveryRequired +→ RecoveryRequired|evidence-bearing TransportLost may consume the same bound owner into BoundBrowserSessionRecovery

+→ recovery custody issues opaque current-revision RecoveryFact values +→ recovery command requires one current RecoveryFact + adapter-defined operation +→ session/incarnation/revision/exact-fact validation occurs before recovery adapter I/O +→ RecoveryContextOperationRequest carries only the selected fact, never sibling recovery ledgers +→ adapter command success/failure leaves Browser Session uncertainty unchanged +→ caller supplies independently qualified proof with one current RecoveryFact +→ settle_recovery_fact revalidates session/incarnation/revision/exact fact before proof I/O +→ exact retained adapter verifies RecoverySettlementRequest through RecoverySettlementPort +→ verifier success retires exactly one fact and advances the recovery revision +→ predecessor/replayed/sibling handles issued under the old revision become stale +→ exact uncertain owned context is removed only for ownership evidence that names that same handle +→ all facts + uncertain hot ownership gone → terminal Ended; ordinary and recovery-command authority are closed +→ proven ordinary destruction removes live hot ownership; failed destruction retains Uncertain ownership ``` -`BrowserSessionIncarnation` is process-local and monotonic. Presentation authority is not persisted across process restart, so restart invalidates outstanding authority rather than requiring a durable counter. Within one running process, the incarnation is checked by the aggregate and passed through the lifecycle port; an adapter that ignores it does not satisfy the ACL contract. +`BoundBrowserSession` is the linear lifecycle-port binding. `BoundBrowserSessionRecovery` preserves that same adapter as a reduced-capability, one-way owner. `DisposableContextCreateRequest`, `DisposableContextCreateCompletion`, `DisposableContextDestroyRequest`, `AuthorizedContextOperationRequest`, `RecoveryContextOperationRequest`, `RecoveryFact`, and `RecoverySettlementRequest

` have private construction fields. Epochs, revisions, protocol ids, and evidence are correlation/provenance rather than standalone bearer authority. -## Lossless recovery evidence +## Transactional remote creation -`DisposableContextCreateError::CreateFailedClean` is valid only when no disposable browser state exists. `CreateFailedUncertain(Some(isolation))` retains the exact known user-context/isolation identity as `BrowserSessionRecoveryEvidence::PartialCreationIsolation`; `None` remains representable when no identity was obtained. Both uncertain cases enter `RecoveryRequired` and mint no authority. +A protocol adapter may stage a successful remote create result as pending when it receives the create request. It must not promote that result into an authorizing binding until Browser Session accepts the exact attempt through `DisposableContextCreateCompletion`. -Duplicate browsing-context or isolation output stores the complete offending `DisposableContextHandle` as `DuplicateAdapterHandle` before recovery quarantine. OriginWeave deliberately does not auto-destroy duplicate output because ownership may be foreign. Failed or unproven destruction records `UnprovenDestruction` with the exact owned handle. Recovery evidence authorizes no browser command; it exists only for a later reviewed reconciliation path. +`DisposableContextCreateRecoveryEvidence` preserves transaction identity that raw handle evidence cannot represent. `FailedUncertain` stores the aggregate-issued attempt epoch even without a complete handle; `DuplicateCandidate` binds an aliased candidate to its rejected attempt; `CompletionUnsettled` binds attempt, disposition, and returned handle when completion settlement cannot be proven. -## Orthogonal transport liveness +Identity-oriented `BrowserSessionRecoveryEvidence` remains independently useful for ownership reconciliation. The two ledgers are separate: a later or rejected same-valued candidate cannot erase a previously accepted ownership fact merely because remote values alias. -Transport liveness is tracked independently from ownership recovery. If transport loss occurs after `RecoveryRequired`, the aggregate keeps `RecoveryRequired`, preserves all recovery evidence, and separately records `transport_lost = true`. The first loss report is observable; repeated reports are idempotent. If loss occurs while `Active`, the lifecycle state becomes `TransportLost` and active context records become uncertain. +Protocol pending/accepted/quarantined tuple storage remains #314/#316 responsibility. Browser Session owns attempt identity, domain accept/reject, current command authority, non-authorizing recovery facts, and deterministic exact-fact retirement after proof verification. -This avoids conflating “ownership uncertain while transport may still be usable for separately authorized reconciliation” with “ownership uncertain and the transport is gone.” +## Same-bound-adapter ordinary authorized operations -## Sequential ABA safety +`AuthorizedContextOperationPort` extends the lifecycle port for post-create presentation work. Browser Session validates session incarnation, isolation identity, browsing-context identity, current presentation state, and context epoch before creating `AuthorizedContextOperationRequest` and routing it to the same `port: P` already consumed by `BoundBrowserSession`. -The sequential ABA hostile case is explicit: aggregate A creates `(S,U,C,epoch=1)`, proves destruction, and ends. Aggregate B later starts with the same external `S`; the adapter may return the same `U/C`, and B also begins at local epoch 1. A's retained authority must still fail before any B adapter I/O. B receives a different `BrowserSessionIncarnation`, and only B's newly minted authority is accepted. +Stale or foreign authority fails before adapter I/O as `AuthorizedContextOperationError::BrowserSession`. Adapter failures remain typed as `AuthorizedContextOperationError::Adapter`. No raw `P`, second adapter, or unrestricted callback is exposed. -The port also receives the incarnation on create/destroy. This closes the prior gap where an aggregate-only nonce could protect token comparison while the browser adapter still keyed destruction by aliasable raw identifiers. +## Recovery-only custody and exact-fact command path -## Standards trace +`BoundBrowserSession::into_recovery(self)` is one-way. It succeeds from `RecoveryRequired`, or from `TransportLost` only if exact unresolved recovery/create-attempt evidence remains. Ownership-clean transport loss cannot mint recovery capability. -The design dossier references the 9 September 2026 WebDriver BiDi Working Draft. A user context has a user-context id set on creation. `browser.createUserContext` creates it, `browsingContext.create` can create a browsing context inside it, and `browser.removeUserContext` removes the selected user context after closing its navigables. +Recovery custody exposes `state()`, read-only recovery ledgers, current-fact issuance, and—when `P: RecoveryContextOperationPort`—`execute_recovery_context_operation(fact, operation)`. It exposes neither raw `P`, inner `BoundBrowserSession`, inner `BrowserSession`, ordinary create/presentation/navigation/cleanup authority, nor normal finish. -OriginWeave does not turn that protocol identifier into policy authority or assume historical non-reuse after removal. `DisposableIsolationId` remains lifecycle addressability. A successful command ACK is insufficient evidence that the disposable boundary is actually gone. +Before recovery-command I/O, Browser Session validates that the supplied `RecoveryFact` belongs to the exact session/incarnation, was issued at the current recovery revision, and still addresses the current ledger/index fact. Foreign facts fail as `RecoveryContextOperationError::AuthorityMismatch`; stale, replayed, shifted, or out-of-range facts fail as `RecoveryContextOperationError::StaleFact`. Both fail before the adapter is invoked. -The active `originweave-bidi` adapter remains separately runtime-qualified against its documented 3 September 2026 revision. Tracking the 9 September publication here does not silently repin that runtime contract. +`RecoveryContextOperationRequest` snapshots the Browser Session id, incarnation, unresolved state, the selected identity-oriented **or** create-attempt recovery fact, and the adapter-defined operation. Its evidence fields are `Option<...>`, not full vectors. Sibling facts are intentionally withheld from the adapter command path. `BoundBrowserSession::dispatch_recovery_operation` remains `pub(crate)`. -## Source and executable evidence +Adapter success or `RecoveryContextOperationError::Adapter(E)` leaves all Browser Session recovery state unchanged. The same current fact may be retried until a successful settlement changes the recovery revision. Command ACK is not destruction or reconciliation proof. After complete settlement reaches `Ended`, the command path returns `RecoveryContextOperationError::RecoveryClosed` before adapter I/O. -| Invariant | Source / test | -|---|---| -| independent Browser Session bounded context | `crates/originweave-browser-session/`; `tests/test_browser_session_lifecycle_contract.py` | -| raw context cannot mint authority | `BrowserSession::presentation_authority`; `disposable_creation_is_the_only_raw_context_entry_to_authority` | -| authority includes non-reused BrowserSessionIncarnation | `PresentationMutationAuthority`; `sequential_incarnation_reuse_rejects_stale_authority` | -| lifecycle port receives the same incarnation | `DisposableContextPort`; `stale_authority_cannot_cross_sequential_session_incarnations` | -| lossless recovery evidence for known partial identity | `BrowserSessionRecoveryEvidence`; `creation_failure_preserves_known_recovery_identity` | -| duplicate adapter handle retained without speculative cleanup | `BrowserSession::create_disposable_context`; `duplicate_adapter_output_preserves_offending_handle` | -| unproven destruction retains exact handle | `BrowserSession::destroy_disposable_context`; `destroy_failure_requires_recovery_before_any_new_authority` | -| transport liveness remains orthogonal to recovery | `BrowserSession::record_transport_loss`; `destroy_failure_retains_handle_and_transport_loss_orthogonally` | -| sequential ABA authority is rejected before I/O | `BrowserSession::context_for_authority_mut`; `stale_authority_cannot_cross_sequential_session_incarnations` | -| normal end requires proved destruction | `BrowserSession::end`; `normal_end_requires_proven_destruction_and_ignores_late_transport_report` | -| incarnation exhaustion fails closed | `allocate_incarnation`; `incarnation_allocator_fails_closed_before_wrap` | +## Proof-bearing exact-fact recovery settlement + +Recovery completion is separate from recovery command execution. `recovery_fact(index)` and `create_attempt_recovery_fact(index)` issue opaque `RecoveryFact` values only for currently addressable facts. Each handle binds Browser Session id, process-local incarnation, ledger kind, index, and current monotonic recovery revision. + +`settle_recovery_fact(fact, proof)` validates: + +1. exact Browser Session id and incarnation; +2. current recovery revision and exact current ledger/index fact; +3. next-revision capacity; +4. retained-adapter proof verification through `RecoverySettlementPort::verify_recovery_settlement(RecoverySettlementRequest)`; +5. exact one-fact retirement; +6. monotonic revision advance. + +`AuthorityMismatch`, `StaleFact`, and `RevisionExhausted` are pre-I/O failures. `RecoverySettlementError::Adapter(E)` is post-verifier/pre-mutation. A failed proof consumes nothing. A successful settlement invalidates every fact handle issued under the previous revision, including unrelated sibling handles, so callers reread custody after mutation. + +The two recovery ledgers remain independently consumable. Retiring `BrowserSessionRecoveryEvidence` never implicitly erases matching `DisposableContextCreateRecoveryEvidence` and vice versa. + +Ownership retirement is alias-safe. Only `UnprovenDestruction`, `RecoveryRequiredOwnedHandle`, or `TransportLossOwnedHandle` may remove an exact matching `Uncertain` hot ownership record. `PartialCreationIsolation`, `DuplicateAdapterHandle`, and `UnsettledAdapterHandle` retire evidence only; a rejected candidate that reuses remote values cannot delete a distinct accepted owner. + +When both ledgers are empty and no uncertain hot ownership remains, Browser Session reaches `Ended`. Recovery custody never recreates `Active`, create authority, `PresentationMutationAuthority`, navigation authority, ordinary cleanup authority, or generic recovery-command authority. + +#316 remains canonical owner of WebDriver BiDi proof qualification, pending/accepted/quarantined tuple truth, remote liveness, event correlation, replay qualification, and concrete recovery commands. A protocol event is evidence, not Browser Session policy authority. #316 maps independently qualified evidence into `RecoverySettlementPort::Proof`; Browser Session validates and consumes only its own exact fact. -Earlier exact-head evidence remains historical only. Exact `ab04f9522e97e1ecd6d914c48cb6f77f087eac3b` was repository GREEN in CI `34463908909` after repairing repository-contract drift, but it still contained the three Browser Session defects above. +## Lossless evidence and bounded hot ownership -The sequential ABA RED was then captured on exact `ec145963ad8fe19c9416f2b3856b94660082dbf7` in CI `34469580144`: Python repository contracts and canonical formatting passed; the Rust `Run tests` step failed at the newly added hostile sequential-incarnation test. That RED is the causal predecessor for the incarnation-aware domain/port repair. No earlier GREEN transfers to the repaired successor. +`CreateFailedClean` is valid only when no disposable browser state exists. Uncertain create, duplicate output, completion failure, failed destroy, sibling invalidation, and transport loss preserve exact facts without granting browser authority. -Protected-main integration is required before capability maturity can be promoted beyond `IMPLEMENTED_ON_ACTIVE_PR`. +Hot command-authority state contains only live or uncertain ownership. Proven ordinary destruction removes the current hot record. Failed destroy retains the exact record as `Uncertain` plus `UnprovenDestruction { context, context_epoch }`. -## Buyer acceptance still open +The 258-generation hostile fixture proves the same raw isolation/context values may be reused after proven destruction while `BrowserContextEpoch` remains monotonic. Immediately after destruction a predecessor authority fails `ContextNotOwned`; after recreation it fails `AuthorityMismatch`. Across aggregate recreation, `BrowserSessionIncarnation` rejects stale authority even when raw ids and local epochs alias. -This slice does not yet prove: +Removing a proven-destroyed record is not durable history deletion. Cross-process recovery and buyer audit history remain separate persistence concerns. -- actual WebDriver BiDi `browser.createUserContext`/`browsingContext.create` integration and incarnation-scoped mapping; -- observed `browser.removeUserContext` post-condition for the exact owned boundary; -- a separately authorized reconciliation service consuming `BrowserSessionRecoveryEvidence`; -- Browser Session authority conversion into BiDi presentation/screen-area private witnesses; -- pinned Chromium post-condition observation after presentation mutation; -- crash/process-restart reconciliation of uncertain disposable contexts; -- 3/3 complete #299 Agent Task browser trials; -- protected-main release, SBOM, provenance, reproducibility, or rollback evidence. +## Abandonment and lifecycle completion -## Reference +`BoundBrowserSession

` and recovery custody are `#[must_use]` linear owners. `Drop` performs no browser I/O. Dropping unresolved custody increments only the process-local `abandoned_bound_session_count()` signal. -Browser Testing and Tools Working Group. (2026, September 9). *WebDriver BiDi* (W3C Working Draft). World Wide Web Consortium. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/ +Successful exact-fact reconciliation updates underlying aggregate state before eventual drop. If every fact and uncertain owner is retired, the aggregate is `Ended`, so dropping a fully reconciled recovery wrapper is not reported as unresolved abandonment. + +## Navigation authority interaction + +The #317 lineage admits observed navigation only for the exact active `(BrowserSessionIncarnation, BrowsingContextId, BrowserContextEpoch)` generation. Admission revokes presentation authority without adapter I/O and mints opaque `NavigationSettlementAuthority`. Commit is non-terminal; positive settlement, typed negative terminal, and download start share one exactly-once closure. A newer navigation supersedes an older witness. Explicit re-establishment alone consumes the next presentation epoch. + +Presentation mutation and lifecycle cleanup are separate. A navigation-invalidated presentation capability cannot authorize mutation or authority-based cleanup; the exact ordinary bound lifecycle owner may still destroy its retained context without reopening presentation authority. Recovery settlement remains separate from navigation settlement and cannot recreate navigation or presentation authority. + +## Browser-issued identity and standards trace + +`DisposableIsolationId` maps to WebDriver BiDi `browser.UserContext` and preserves protocol text losslessly. OriginWeave does not normalize that address or treat it as command authority. + +WebDriver BiDi publication freshness is referenced through the canonical owner receipt `docs/traceability/webdriver-bidi-publication-current.md`; this Browser Session trace does not restate dated Working Draft currentness. Chromium/runtime compatibility remains independently qualified from publication metadata. A command acknowledgement is insufficient proof that a disposable boundary is gone. + +## Source and executable evidence + +| Invariant | Source / test | +|---|---| +| independent Browser Session bounded context | `crates/originweave-browser-session/`; `tests/test_browser_session_lifecycle_contract.py` | +| structural lifecycle-port ownership; no public raw adapter | `BoundBrowserSession`; lifecycle hostile tests | +| aggregate-issued create attempt | `DisposableContextCreateRequest::attempt_epoch`; transaction fixture | +| same consumed adapter for ordinary work | `AuthorizedContextOperationPort`; `authorized_context_operation.rs` | +| same consumed adapter for recovery commands | `RecoveryContextOperationPort`; `RecoveryContextOperationRequest`; `recovery_same_adapter_operation.rs` | +| recovery command requires one current fact | `recovery_operation_exact_fact_scope.rs`; `tests/test_browser_session_recovery_operation_contract.py` | +| foreign/stale operation fact rejected before I/O | `recovery_operation_exact_fact_scope.rs` | +| command success/failure does not settle ownership | `recovery_same_adapter_operation.rs` | +| terminal settlement closes recovery command path | `recovery_operation_terminal_closure.rs` | +| exact proof-bearing recovery fact | `RecoveryFact`; `RecoverySettlementRequest`; `RecoverySettlementPort`; `settle_recovery_fact` | +| replay/sibling/foreign proof cases | `recovery_exact_fact_settlement.rs` | +| recovery settlement surface remains opaque/current | `tests/test_browser_session_recovery_settlement_contract.py` | +| unproven destroy preserves exact handle + epoch | `UnprovenDestruction`; destroy-failure tests | +| ownership-clean transport loss cannot mint recovery | `recovery_handoff_requires_unresolved_ownership.rs` | +| proven destruction bounds hot ownership | `proven_destroy_releases_hot_ownership.rs` | +| recovery custody cannot regain ordinary authority | recovery rustdoc `compile_fail`; repository contracts | +| navigation witness is opaque and generation-bound | navigation owner tests; `tests/test_browser_session_navigation_owner_surface_contract.py` | +| canonical W3C publication receipt boundary | `tests/test_browser_session_webdriver_bidi_publication_trace.py`; `docs/traceability/webdriver-bidi-publication-current.md`; ADR 0114 | + +Historical predecessor CI/review receipts do not transfer to the current head. Active-PR source remains non-shipment until exact-head repository contracts, canonical rustfmt, locked tests, strict Clippy, rustdoc/API docs, production function/line/region/branch coverage at 100%, required review/security gates, and protected `main` integration are observed. + +## Current integration boundary + +#317 owns Browser Session domain policy, same-adapter exact-fact recovery-command custody, and generic exact-fact settlement. #318/#321 own stacked hostile navigation/ABA acceptance and doctoring only. #316 owns WebDriver BiDi proof qualification, pending/accepted/quarantined state, event correlation, remote liveness, protocol-specific recovery semantics, and real-browser adapter integration. + +Real Chromium navigation, interaction, cleanup, recovery, and browser-observed post-condition evidence remains required before shipment. Immutable release, signed artifact, SBOM, provenance, reproducibility, and rollback evidence remain separate release gates. diff --git a/docs/traceability/browser-session-linker-default-library-search-path-authority.md b/docs/traceability/browser-session-linker-default-library-search-path-authority.md new file mode 100644 index 000000000..63c564b7c --- /dev/null +++ b/docs/traceability/browser-session-linker-default-library-search-path-authority.md @@ -0,0 +1,43 @@ +# Browser Session GNU ld default library search-path authority + +## Problem + +OriginWeave treats repository-owned Cargo compiler/linker configuration as reviewed Browser Session build provenance. GNU `ld` accepts `-Y path` to add `path` to its default library search path. GNU `ld` also permits a single-letter option operand to be attached directly to the option letter, so `-Ytools/shadow-libs` is a valid spelling of the same authority change. + +Before this repair, the shared direct-linker classifier covered explicit `-L` / `--library-path`, `-rpath`, `--rpath`, `-rpath-link`, and `--rpath-link` inputs but did not classify compact `-Ypath`. Repository-owned Cargo `rustflags` or `rustdocflags` could therefore forward a compact `-Ypath` through `-Wl,`, `--for-linker=`, or doctest compiler arguments and change default library discovery outside the reviewed Cargo package/source closure. + +## Owner boundary + +`tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for repository-selected compiler, rustdoc, toolchain, linker execution, and external-input authority. This repair extends that existing classifier; it does not introduce another Cargo topology scanner. + +The supplemental hostile fixture `tests/test_browser_session_linker_default_library_search_path_authority_contract.py` imports the canonical authority contract and verifies only the additional GNU `-Y` behavior. + +## Evidence chain + +- Structural RED: `b599ea47286d253e432e1235bf1d938f42e28919` adds hostile build/target `rustflags`, build `rustdocflags`, and rustdoc doctest-forwarding cases using compact `-Ypath`. The unrelated `-z relro` control remains allowed. +- Minimal causal repair: `d4672136b740575d72e2d9ea64b6b65f1cb09526` extends `_linker_option_selects_runtime_search_path()` so exact `-Y` and compact `-Ypath` are classified by the existing direct-linker authority path. +- Formatting-only follow-up: `43ce517e26c203418b872c3105a24cdac2c6e5c4` restores the pre-existing blank-line and end-of-file formatting changed incidentally by the contents update. It does not change policy semantics. + +This chain is source-semantic evidence. It is not a substitute for exact-head hosted repository/security execution. + +## Decision + +Fail closed when Git-owned Cargo flags forward GNU `-Y` default-library search-path selection. Do not add a path allowlist at this layer. + +A pathname alone does not establish the immutable identity of libraries that will later be discovered through that search root. A future reviewed exception therefore needs, in the same release evidence chain, the selected library artifact identities and digests, producer provenance, containment and symlink policy, exact linker/toolchain compatibility, SBOM/provenance binding, reproducibility evidence, and rollback behavior. + +## Security effect + +The Browser Session build contract no longer permits repository-owned Cargo configuration to change GNU `ld`'s default library lookup through the compact `-Ypath` grammar while leaving the reviewed Cargo package/source closure unchanged. Existing explicit runtime/link-time search-path controls continue to use the same shared classifier. + +The repair deliberately does not prohibit unrelated direct-linker controls that do not select an external search root, such as `-z relro`. + +## Residual authority + +This repository contract does not claim control over environment or direct-CLI injection, ancestor or `$CARGO_HOME` configuration, externally selected linker/toolchain binaries, linker configuration outside the repository, sysroot contents, runner images, or externally restored build/cache artifacts. Those remain CI/release supply-chain provenance surfaces and must be proven by release evidence rather than silently copied into the Browser Session domain. + +## Primary reference + +Free Software Foundation. (2026). *The GNU linker*. GNU Binutils documentation. https://sourceware.org/binutils/docs/ld.pdf + +The GNU linker documents `-Y path` as adding `path` to the default library search path for Solaris compatibility. Its command-line grammar also permits arguments to single-letter options either attached directly to the option letter or supplied as the immediately following argument. diff --git a/docs/traceability/browser-session-linker-default-script-authority.md b/docs/traceability/browser-session-linker-default-script-authority.md new file mode 100644 index 000000000..ed4df42e9 --- /dev/null +++ b/docs/traceability/browser-session-linker-default-script-authority.md @@ -0,0 +1,47 @@ +# Browser Session default linker script authority traceability + +Status: Draft contract evidence on PR #317. This document does not claim hosted repository/security GREEN. + +## Problem + +The Browser Session Cargo compiler-authority contract already fails closed on explicit GNU/LLD linker scripts selected through `-T` / `--script`. A distinct spelling remained unmodeled: GNU `ld` also accepts `-dT scriptfile` / `--default-script=scriptfile`, and LLD documents the same default-script interface. The default script is still an external file that controls linker behavior; GNU differs only in delaying its processing until the rest of the command line has been processed. + +At the predecessor exact head, `_linker_option_selects_script()` recognized `-T`, attached `-T...`, `--script`, and `--script=...`, but did not recognize `-dT` or `--default-script`. Repository-owned `rustflags` or `rustdocflags` could therefore forward an equals-form default script through `-Wl,`, `--for-linker=`, or `-Xlinker` while the reviewed Cargo package/source closure remained unchanged. + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. +- `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for repository-selected compiler, rustdoc, linker execution, and external input authority. +- The repair must reuse the existing direct-linker parser for build/target/profile `rustflags`, `rustdocflags`, and rustdoc doctest compiler forwarding. A second Cargo or linker scanner is not introduced. +- Unrelated linker hardening remains permitted. `-Wl,-z,relro` is retained as an allowed control because it does not select a script or other external input. +- Environment/direct-CLI linker flags, the ambient linker binary/version, and externally supplied toolchain contents remain CI/release supply-chain evidence surfaces. + +## RED + +Commit `6bd948fd5cfbf71ecebca4e0001fc7cfcffb4da5` adds `tests/test_browser_session_linker_default_script_authority_contract.py`. Hostile fixtures cover: + +- build `rustflags` forwarding `-Wl,--default-script=...`; +- target `rustflags` forwarding `--for-linker=--default-script=...`; +- build `rustdocflags` forwarding the same selector through `-Xlinker`; +- rustdoc `--doctest-build-arg` forwarding a default script; +- an allowed `-Wl,-z,relro` control. + +The predecessor classifier did not match the equals-form `--default-script=...`, so these fixtures preserve an actual source-semantic provenance gap rather than asserting a broad deny rule. + +## Decision and repair + +Commit `63f12526044b10ffa049c3fbf9701bc31760d44f` extends the existing linker-script selector with the documented GNU/LLD spellings `-dT` and `--default-script`. Exact `-dT` / `--default-script`, attached `-dT...`, and equals `--default-script=...` are classified alongside the already reviewed `-T` / `--script` forms. The existing `-Wl,`, `--for-linker=`, `-Xlinker`, build, target, profile, rustdoc, and doctest-forwarding paths therefore inherit the repair without another topology or policy owner. + +The fix intentionally does not path-allowlist linker scripts. A reviewed pathname does not prove immutable file contents, symlink containment, the complete input set introduced by script commands, linker/toolchain identity, or reproducible output. A future exception requires an immutable script digest and artifact identity, containment proof, transitive input provenance, linker/toolchain compatibility, SBOM/attestation evidence, reproducibility, and rollback qualification. + +## Security and release consequence + +A repository-owned default linker script can influence output layout and symbol/input resolution just as an explicit linker script can. This repair closes the modeled Git-owned Cargo path without claiming anything about ambient linker defaults, direct CLI invocation, runner images, or release-time toolchain integrity. + +Hosted exact-head execution, whole-PR independent review closure, owned production rustdoc/test/edge coverage, and immutable release acceptance remain separate gates. + +## Primary references + +Free Software Foundation. (2025). *The GNU linker* (GNU Binutils 2.45), `-dT scriptfile` / `--default-script=scriptfile`. https://sourceware.org/binutils/docs-2.45/ld.pdf + +LLVM Project. (2026). *Linker Script implementation notes and policy*. LLD documentation. Retrieved September 18, 2026, from https://lld.llvm.org/ELF/linker_script.html diff --git a/docs/traceability/browser-session-linker-driver-wrapper-authority.md b/docs/traceability/browser-session-linker-driver-wrapper-authority.md new file mode 100644 index 000000000..e174fb682 --- /dev/null +++ b/docs/traceability/browser-session-linker-driver-wrapper-authority.md @@ -0,0 +1,40 @@ +# Browser Session GCC linker-driver wrapper authority + +Status: Draft source-level traceability for PR #317. This document does not claim hosted exact-head repository/security GREEN. + +## Problem + +The Browser Session Cargo execution-authority contract already fails closed on direct linker selection, driver program-search replacement, opaque driver response files, modeled linker plugin loading, and GCC specs files. GCC has a separate driver-level execution extension: `-wrapper` runs every GCC subcommand under a caller-selected wrapper program. + +Rust documents `-C link-arg` and `-C link-args` as arguments appended to the linker invocation. On the GNU-compatible Unix path used by the current contract, that invocation may be the GCC driver. A Git-owned Cargo configuration can therefore pass `-wrapper tools/review-wrapper,--args` through rustc/rustdoc linker flags while keeping the nominal compiler driver, reviewed Rust source closure, and visible Cargo `linker` setting unchanged. The wrapper becomes part of the build/link execution TCB. + +GCC's current official documentation states that `-wrapper` invokes all subcommands under the named wrapper program. A Debian GCC 14.2.0 `gcc -###` reproduction with `-wrapper /bin/echo,--` showed the link subprocess rendered as `/bin/echo -- .../collect2 ...`, confirming that the option reaches the link-stage subcommand path. This runtime realism probe is supplemental evidence only; it is not OriginWeave exact-head CI. + +## RED + +Commit `2ae24ca196e54c59068ce0ff243bbbef2dcc0d83` adds hostile repository fixtures for both Cargo encodings used by the existing shared parser: + +- repeated `-C link-arg=-wrapper` plus `-C link-arg=tools/review-wrapper,--args`; +- one `--codegen=link-args=-wrapper tools/review-wrapper,--args` value. + +The predecessor classifier accepted exact `-wrapper` because it only recognized `@file`, GCC `-specs`, `-fuse-ld=`, and driver `-B` as driver execution-authority surfaces. The fixtures consume the canonical Browser Session Cargo compiler-authority contract rather than duplicating Cargo topology discovery. + +## Decision and repair + +Commit `9aaa60019e2b8e150bd6e2f6d5f475442786825e` changes only the existing `_linker_driver_argument_selects_executable` classifier. Exact GCC `-wrapper` now fails closed before the subsequent wrapper-program argument can extend link execution authority. Existing handling for response files, specs, linker selection, program-search prefixes, linker plugins, and ordinary non-execution linker arguments is unchanged. + +The contract intentionally rejects the option token itself even when malformed or missing its operand. Allowing a repository-owned `-wrapper` requires a separate immutable wrapper identity/provenance contract on the same reviewed tree; documenting a path string is not sufficient. + +## Boundary and residual risk + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. +- `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the shared Git-owned Cargo execution-authority classifier. +- This repair does not authorize a wrapper executable or expand the future BiDi adapter allowlist. +- Environment-owned tool variables and image/toolchain selection remain CI/supply-chain owner concerns. +- Non-GNU driver mechanisms, linker scripts, other arbitrary linker arguments, and external toolchain provenance remain separate review surfaces. + +## Primary references + +Free Software Foundation. (2026). *Using the GNU Compiler Collection (GCC): Overall options*. https://gcc.gnu.org/onlinedocs/gcc/Overall-Options.html + +Rust Project Developers. (2026). *The rustc book: Codegen options*. https://doc.rust-lang.org/rustc/codegen-options/index.html diff --git a/docs/traceability/browser-session-linker-implicit-script-authority.md b/docs/traceability/browser-session-linker-implicit-script-authority.md new file mode 100644 index 000000000..d9093a3f0 --- /dev/null +++ b/docs/traceability/browser-session-linker-implicit-script-authority.md @@ -0,0 +1,61 @@ +# Browser Session extensionless implicit linker-script authority + +## Decision + +Browser Session Cargo execution/input provenance treats every unconsumed non-option linker token as an external native input, including extensionless bare filenames. The shared parser consumes only linker option operands whose arity and semantics are explicitly modeled; today that narrow allowlist contains GNU `ld`/driver `-z `. + +This is intentionally not a blanket ban on non-option words. A token such as `relro` is allowed only when it is consumed as the operand of the modeled `-z` option. The same bare token in positional position fails closed. A future exception for a repository artifact or script requires an explicit, versioned provenance contract for the referenced artifact and its transitive native inputs. + +## Problem and threat + +The reviewed production package/source closure and the nominal linker executable can remain unchanged while Git-owned Cargo `rustflags` append an extensionless file through `-C link-arg` or `-C link-args`. + +The Rust compiler documents that `link-arg` appends one argument to the linker invocation and that `link-args` appends multiple arguments. On Unix-like targets using a C compiler as linker driver, `-Wl,$ARG` forwards an argument to the underlying linker. + +GNU `ld` documents that non-option arguments are object files or archives and that an input whose format is not recognized is parsed as an implicit linker script. Such a script may contain `INPUT` or `GROUP`, which can introduce additional native inputs. GNU `ld` also states that an object argument may not appear between an option and its required operand. That command-line grammar is why positional-input classification must be arity-aware rather than suffix- or path-shape-based. + +Therefore both `tools/review-bypass-input` and the bare filename `review-bypass-input` can represent transitive native-input authority. File suffixes and path separators are insufficient provenance boundaries. + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. +- `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for Git-owned Cargo compiler/linker execution and input authority. +- The repair must not ban unrelated rustflags or modeled option operands such as the `relro` keyword in `-z relro`. +- `-Wl,`, `--for-linker=`, and repeated `-Xlinker` forwarding must reach the same direct-linker authority classifier. +- No linker or driver acknowledgement is treated as evidence that the resulting binary contains only reviewed inputs. + +## RED → repair evidence + +### Path-shaped extensionless inputs + +- Predecessor exact head: `202ba6c92faf7a34d233690c3923680337ec0e65`. +- RED: `8f5e49f5fe63d99773d25f13a3ab50648e02ac92` adds `tests/test_browser_session_linker_implicit_script_contract.py`. It supplies an extensionless `tools/review-bypass-input` whose contents are `INPUT(tools/review-bypass-object.o)` through direct, `-Wl,`, `--for-linker=`, and `-Xlinker` forms. The predecessor suffix-only classifier does not reject those path operands. +- Repair: `cb95d5d37050bb7da70f1782da2e63a9b4583734` extends the existing positional-input classifier so a non-option token containing `/` or `\\` fails closed before suffix classification. +- Control: `b03980261159ca90788c1ccf9cc5e750d974976c` preserves `-Wl,-z,relro` and the existing option-only `--as-needed` control. + +### Bare extensionless inputs + +- Predecessor exact head: `19dae976f1dcfdf261ded04c43cade986ade7712`. +- RED: `ed86b335b4aa6cde223fe2e614bb26d39f789d8a` adds `tests/test_browser_session_linker_bare_implicit_script_contract.py`. It supplies a bare `review-bypass-input` through direct, `-Wl,`, `--for-linker=`, and `-Xlinker` forms while retaining direct and forwarded `-z relro` controls. The predecessor path/suffix classifier does not reject the bare positional token. +- Repair: `5f070bf0b87ae513cf06badda29914e579f850ee` replaces path/suffix heuristics with an arity-aware direct-linker token parser. Every unconsumed non-option token fails closed. `-z` consumes exactly one modeled keyword operand; repeated `-Xlinker` payloads are reconstructed into one direct-linker token sequence before classification, so `-Xlinker -z -Xlinker relro` remains permitted while `-Xlinker review-bypass-input` does not. + +No second Cargo topology/config authority is introduced. Direct linker arguments and the existing GNU-style forwarding forms consume the same authority classifier. + +## Residual risk and removal conditions + +This slice does **not** claim universal linker-grammar closure. + +- Only option arity that has an explicit harmless-operand contract is consumed. Additional GNU linker options with separate operands remain fail closed until their semantics are modeled with positive and hostile fixtures. +- Non-GNU linker/driver grammars may expose different option arities and input-control surfaces. They require authoritative target-specific semantics and hostile fixtures before being added to the shared classifier. +- Runtime environment injection (`RUSTFLAGS`, `CARGO_ENCODED_RUSTFLAGS`), direct `cargo rustc -- ...`, sysroot/toolchain composition, and custom target/toolchain behavior remain separate authority surfaces. +- Static argument classification proves admission policy, not the actual final link command or artifact composition. Release evidence still requires exact-head executable provenance and reproducibility evidence. + +An input exception may be relaxed only when the referenced file and every transitive native input are immutable, hashed, reviewed, bound to exact build provenance, and exercised by current-head executable evidence. Until then the contract remains fail closed. + +## Primary references + +Rust Project. (2026). *The rustc book: Codegen options — link-arg and link-args*. https://doc.rust-lang.org/rustc/codegen-options/index.html#link-arg + +GNU Project. (2026). *GNU ld: Options*. https://sourceware.org/binutils/docs/ld/Options.html + +GNU Project. (2026). *GNU ld: Implicit linker scripts*. https://sourceware.org/binutils/docs/ld/Implicit-Linker-Scripts.html diff --git a/docs/traceability/browser-session-linker-input-remap-authority.md b/docs/traceability/browser-session-linker-input-remap-authority.md new file mode 100644 index 000000000..9b9e31429 --- /dev/null +++ b/docs/traceability/browser-session-linker-input-remap-authority.md @@ -0,0 +1,38 @@ +# Browser Session linker input-remap authority + +Status: Proposed until exact-head hosted repository/security checks and independent review complete. + +## Problem + +GNU `ld` can rewrite the link input graph after Cargo/rustc have selected the reviewed inputs. `--remap-inputs=pattern=filename` substitutes a different filename before the linker opens an input, and `--remap-inputs-file=file` loads the remapping policy from an external file. The remapping also applies to files named by `INPUT` statements in linker scripts. GNU `ld` accepts multi-letter options with either one or two leading dashes, so `-remap-inputs=...` and `-remap-inputs-file=...` are equivalent spellings. + +For OriginWeave this is provenance authority, not a linker-tuning preference. A repository-owned Cargo `rustflags`/`rustdocflags` value could otherwise redirect a reviewed native/library input to an unreviewed object, archive, shared library, or `/dev/null` while leaving the Cargo dependency graph unchanged. + +## Owner boundary + +Browser Session keeps repository-selected compiler/rustdoc/toolchain/linker input authority in `tests/test_browser_session_cargo_compiler_authority_contract.py`. Production package/source topology remains owned by `tests/test_browser_session_trusted_adapter_boundary.py`; this repair does not duplicate that scanner or move dependency-source authority. + +The canonical direct-linker parser now classifies both inline and file-backed input remapping as authority-extending, including GNU single-dash aliases. Existing `-Wl,`, `--for-linker=`, `-Xlinker`, `-C link-arg`, `-C link-args`, build/target `rustflags`, `rustdocflags`, and rustdoc doctest forwarding continue to converge on the same parser. + +## RED → repair + +- RED `c3416fad876bcd15477521b666f18c3c7a3a5cff` adds hostile fixtures for inline remapping, file-backed remapping, GNU single-dash aliases, build/target flags, rustdoc flags, and doctest compiler forwarding. `-Wl,-z,relro` remains an allowed control. +- Repair `333195e3001357237a3844df34729ff730da99dc` adds `_linker_option_remaps_inputs()` to the existing direct-linker authority classifier and changes no Cargo topology owner. + +## Decision + +Reject repository-selected input remapping by default. + +A pathname allowlist is insufficient because a path does not prove the selected artifact's content identity, producer, toolchain compatibility, symlink containment, or reproducibility. A future exception must bind the remap rule and every selected replacement artifact to immutable digests, producer/source identity, exact linker/toolchain compatibility, SBOM/provenance evidence, containment, deterministic rebuild evidence, expiry/invalidation rules, and rollback. + +## Residual authority + +Environment/direct-CLI linker flags, compiler-driver defaults, runner filesystem contents, linker distribution/version and `PATH`, externally restored build/cache state, sysroot contents, and runtime deployment filesystem identity remain CI/release supply-chain evidence surfaces rather than repository-source exceptions. + +## Evidence + +GNU Binutils documents that `--remap-inputs=pattern=filename` changes input filenames before they are opened, `--remap-inputs-file=file` loads remappings from a file, `/dev/null`/`NUL` can suppress an input, and linker-script `INPUT` references are affected. It also documents that multi-letter options accept one or two leading dashes and may take `=`-joined operands. + +### Reference + +Free Software Foundation. (2026). *GNU linker: Command-line options*. GNU Binutils. https://sourceware.org/binutils/docs/ld/Options.html (retrieved September 19, 2026). diff --git a/docs/traceability/browser-session-linker-just-symbols-input-authority.md b/docs/traceability/browser-session-linker-just-symbols-input-authority.md new file mode 100644 index 000000000..c160895ee --- /dev/null +++ b/docs/traceability/browser-session-linker-just-symbols-input-authority.md @@ -0,0 +1,58 @@ +# Browser Session linker `--just-symbols` / `-R` input authority + +Status: Draft + +## Problem + +OriginWeave's Browser Session build-provenance boundary already rejects positional native inputs, linker scripts, response files, plugins, custom sysroots, executable selectors, and modeled library-search inputs. A remaining GNU-compatible linker form could still inject an external file without becoming an unconsumed positional token: + +- `--just-symbols=` +- compact `-R` + +GNU `ld` reads symbol names and absolute addresses from the `-R` / `--just-symbols` operand without relocating or including that file in the output. The same `-R` spelling is treated as an rpath when its operand is a directory. GNU `ld` also permits single-letter option operands to be joined to the option letter. Therefore both forms can change link semantics while bypassing a classifier that only rejects positional native inputs. + +Rust exposes the path through `-C link-arg` and `-C link-args`; on Unix-like targets using a compiler driver, rustc documents `-C link-arg=-Wl,$ARG` as the way to pass an argument to the actual linker. Repository-owned Cargo `rustflags` and `rustdocflags` therefore form a reviewed provenance boundary rather than an ordinary optimization surface. + +## Decision + +`tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for repository-selected Cargo compiler/rustdoc/linker execution and input authority. The existing direct-linker classifier now fails closed on: + +- split `-R` and `--just-symbols` selectors; +- compact `-R`; +- `--just-symbols=`. + +The rule is intentionally shared by build/target `rustflags`, build/target `rustdocflags`, and rustdoc `--doctest-build-arg` forwarding. No second Cargo topology or linker scanner was introduced. `tests/test_browser_session_trusted_adapter_boundary.py` remains the production Cargo topology/source-closure owner. + +The rule does not ban ordinary linker hardening options. `-Wl,-z,relro` remains an allowed control in the focused hostile fixture. + +## RED → repair evidence + +- RED: `f271a2aefe58d12f988ca3c0b89c9b917a5ca550` adds `tests/test_browser_session_linker_just_symbols_input_authority_contract.py` and demonstrates that equals/compact just-symbols forms were not classified by the predecessor shared authority. +- Repair: `7b75fa31291fba28867331a72338174848e4a28c` adds `_linker_option_selects_just_symbols_or_rpath()` to the existing direct-linker authority parser and routes it through the existing `rustflags:codegen linker`, `rustdocflags:codegen linker`, and doctest compiler-authority call sites. + +The focused fixture covers build `rustflags`, target `rustflags`, build `rustdocflags`, rustdoc doctest forwarding, and an ordinary `-z relro` control. + +## Security and provenance effect + +A Git-owned Cargo configuration can no longer select an unreviewed symbol-address file with the modeled GNU-compatible `--just-symbols=` or compact `-R` forms without tripping the Browser Session provenance contract. If `-R` names a directory, the same fail-closed decision prevents repository configuration from silently injecting an rpath through this ambiguous spelling. + +This is a source-semantic contract. It does not by itself establish hosted executable GREEN, compiler-driver parity for every non-GNU linker, or release provenance. + +## Residual authority + +The following remain outside this leaf repository-source contract and require CI/release or linker-family evidence rather than duplicated OriginWeave ownership: + +- environment- or direct-CLI-injected rustc/rustdoc/linker arguments; +- linker-family-specific symbol/control-file mechanisms that are not GNU-compatible grammar already modeled by the shared parser; +- ambient compiler/linker binaries, sysroot contents, runner image, and restored build artifacts; +- immutable release evidence tying the effective toolchain, linker, arguments, SBOM, and provenance to the shipped artifact. + +Any future exception for a symbol-address input must prove immutable artifact identity and digest, producer provenance, exact toolchain/linker compatibility, purpose, containment, reproducibility, and rollback in the same reviewed delta. A pathname allowlist is insufficient. + +## References + +Free Software Foundation. (n.d.). *GNU ld: Command-line options*. GNU Binutils documentation. https://sourceware.org/binutils/docs/ld/Options.html + +The Rust Project Developers. (n.d.). *Codegen options: `link-arg` and `link-args`*. The rustc book. https://doc.rust-lang.org/rustc/codegen-options/ + +Retrieved 2026-09-19. diff --git a/docs/traceability/browser-session-linker-library-alias-authority.md b/docs/traceability/browser-session-linker-library-alias-authority.md new file mode 100644 index 000000000..ee9a2cb9c --- /dev/null +++ b/docs/traceability/browser-session-linker-library-alias-authority.md @@ -0,0 +1,52 @@ +# Browser Session linker `--library` alias authority + +Status: source-semantic repair; hosted exact-head execution evidence is still required before acceptance. + +## Problem + +The Browser Session Cargo/compiler authority contract already fails closed for native-library selectors such as `-lNAME`, `-l NAME`, `-L`, and `--library-path`. LLVM LLD also accepts the GNU-compatible long form `--library=NAME` (and the corresponding separated alias). At LLVM revision `851eb5a97ba67b4e8ebec39fb821c144db67a10a`, `lld/ELF/Options.td` defines `-l` as `Search for library ` and declares both `library` aliases: + +- `Separate<["--", "-"], "library">` +- `Joined<["--", "-"], "library=">` + +Primary source: `https://github.com/llvm/llvm-project/blob/851eb5a97ba67b4e8ebec39fb821c144db67a10a/lld/ELF/Options.td`. + +Before this repair, the shared classifier rejected compact single-dash `-l...` forms but did not classify joined double-dash `--library=...`. A Git-owned `rustflags`/`rustdocflags` path could therefore forward `--library=review_bypass` through `-C link-arg`, `-Wl,`, `--for-linker=`, or doctest compiler forwarding without being recognized as an external native-library selector. + +## Authority boundary + +`tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for Git-owned Cargo compiler/linker input authority. Production Cargo package/source topology remains owned by `tests/test_browser_session_trusted_adapter_boundary.py`; this repair does not duplicate that discovery logic. + +## RED → repair + +- RED `30026004a1108aa850d084c0ba06551d474b050c` adds `tests/test_browser_session_linker_library_alias_authority_contract.py`. It requires joined `--library=review_bypass` to fail closed through ordinary build `rustflags` and rustdoc doctest compiler forwarding, while keeping `-Wl,-z,relro` as an allowed typed control. +- Repair `a168131b65d25f5a4625b77b259492a1b5e692a9` extends the existing shared external-input classifier with `--library` and `--library=`. The RED-to-repair delta in the canonical authority file is two additions/two replacements; no second Cargo/linker scanner was introduced. + +The repair deliberately treats library-name selection as input authority even when the name is not a pathname. The selected bytes still depend on linker search roots, sysroot/toolchain state, and the resolved library artifact. A name allowlist alone does not prove artifact identity or provenance. + +## Invariant + +Git-owned Cargo flags must not select an additional native library through GNU-compatible long-form `--library` syntax unless that input is covered by an explicit reviewed provenance contract. This applies equally when the option is forwarded through rustdoc doctest compilation. + +## Rejected alternatives + +- **Allow known library names:** rejected because a stable name does not identify the resolved bytes, producer, search root, ABI, or toolchain. +- **Add a second focused scanner:** rejected because it would split policy ownership from the canonical Cargo/compiler authority contract. +- **Rely on positional-token fallback:** rejected because joined `--library=NAME` keeps the library selector and operand in one option token. + +## Acceptance and release evidence + +If OriginWeave later needs to permit a repository-selected native library, the same reviewed delta must prove at least: + +1. immutable or integrity-verified artifact identity and producer provenance; +2. linker/toolchain, target, ABI, and architecture compatibility; +3. bounded search-root/sysroot resolution with symlink containment; +4. SBOM and build provenance for the resolved artifact; +5. reproducible independent rebuild or equivalent byte-identity evidence; +6. invalidation and rollback behavior when the artifact or toolchain changes. + +This source-semantic repair is not hosted GREEN. Exact-head required workflows, repository/security gates, independent current-head review, and the broader Browser Session acceptance chain remain mandatory before merge or release. + +## Reference + +The LLVM Project. (2026). *LLD ELF option definitions* (revision `851eb5a97ba67b4e8ebec39fb821c144db67a10a`) [Source code]. GitHub. `lld/ELF/Options.td`. diff --git a/docs/traceability/browser-session-linker-mri-script-authority.md b/docs/traceability/browser-session-linker-mri-script-authority.md new file mode 100644 index 000000000..998f6fe70 --- /dev/null +++ b/docs/traceability/browser-session-linker-mri-script-authority.md @@ -0,0 +1,47 @@ +# Browser Session MRI linker script authority traceability + +Status: Draft contract evidence on PR #317. This document does not claim hosted repository/security GREEN. + +## Problem + +GNU `ld` supports an alternate MRI-compatible linker command language through `-c MRI-commandfile` / `--mri-script=MRI-commandfile`. The command file is an external linker input and, if it is not in the current directory, GNU `ld` searches preceding `-L` directories for it. The Browser Session direct-linker classifier already failed closed on GNU/LLD general-purpose linker scripts and default linker scripts, but the equals-form `--mri-script=...` was not a recognized script selector. + +A repository-owned `rustflags` or `rustdocflags` value could therefore forward `--mri-script=...` through `-Wl,`, `--for-linker=`, or `-Xlinker` while the reviewed Cargo package/source closure remained unchanged. + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. +- `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for repository-selected compiler, rustdoc, linker execution, and external input authority. +- The repair must reuse the shared direct-linker parser for build/target/profile `rustflags`, `rustdocflags`, and rustdoc doctest forwarding. +- `-Wl,-z,relro` remains an allowed control because it does not select an external script or command file. +- Ambient linker flags, direct CLI invocation, runner/toolchain contents, and the linker implementation/version remain CI/release provenance surfaces. + +## RED + +Commit `975e35448aafd9aba57935897431841a29101864` adds `tests/test_browser_session_linker_mri_script_authority_contract.py` with hostile fixtures for: + +- build `rustflags` forwarding `-Wl,--mri-script=...`; +- target `rustflags` forwarding `--for-linker=--mri-script=...`; +- build `rustdocflags` forwarding the selector through `-Xlinker`; +- rustdoc `--doctest-build-arg` forwarding an MRI command file; +- an allowed `-Wl,-z,relro` control. + +At the predecessor exact head, `--mri-script=...` was neither a known script selector nor a positional input because it begins with `-`. The equals form therefore preserved a concrete source-semantic bypass. + +## Decision and repair + +Commit `5a066aacff29534d934f85b203121be5125347c4` extends the existing linker-script classifier with the documented GNU spellings `-c` and `--mri-script`, plus `--mri-script=...`. This is a shared-owner repair: `-Wl,`, `--for-linker=`, `-Xlinker`, build/target/profile flags, rustdoc, and doctest forwarding continue to consume one direct-linker authority classifier. + +The short split form `-c MRI-commandfile` was already incidentally rejected because the following filename became a positional native input. Modeling `-c` explicitly makes the policy reflect linker semantics instead of relying on that incidental parse outcome. The equals-form long option is the material bypass closed by this generation. + +A pathname allowlist was rejected. An MRI command file is executable linker configuration in the provenance sense: pathname review alone does not prove immutable contents, transitive inputs, search-path resolution, symlink containment, linker/toolchain compatibility, reproducibility, SBOM/attestation, or rollback. Any future exception requires versioned immutable artifact identity and those release properties. + +## Security and release consequence + +This closes the modeled Git-owned Cargo path in which an MRI command file could alter linker behavior without entering the reviewed source/dependency topology. It does not claim integrity of ambient/default linker state, direct CLI flags, runner images, or externally restored toolchain artifacts. + +Hosted exact-head execution, whole-PR review closure, owned production rustdoc/test/edge coverage, and immutable release acceptance remain separate gates. + +## Primary reference + +Free Software Foundation. (2025). *The GNU linker* (GNU Binutils 2.45), `-c MRI-commandfile` / `--mri-script=MRI-commandfile`. https://sourceware.org/binutils/docs-2.45/ld.pdf diff --git a/docs/traceability/browser-session-linker-plugin-lto-authority.md b/docs/traceability/browser-session-linker-plugin-lto-authority.md new file mode 100644 index 000000000..3a46e0a34 --- /dev/null +++ b/docs/traceability/browser-session-linker-plugin-lto-authority.md @@ -0,0 +1,47 @@ +# Browser Session rustc linker-plugin-LTO authority traceability + +Status: Draft contract evidence on PR #317. This document records a source-semantic RED→repair chain and does not claim hosted repository/security GREEN. + +## Problem + +The Browser Session Cargo compiler-authority contract already fails closed on linker plugins selected through linker arguments such as GNU `-plugin` / `--plugin`. That does not cover rustc's own `linker-plugin-lto` codegen option. + +Rust documents `-C linker-plugin-lto` as the control that defers LTO to the native link step. Its documented values are the usual boolean forms or a **path to the linker plugin**. The rustc book gives an explicit example using `-Clinker-plugin-lto="/path/to/LLVMgold.so"`. A Git-owned Cargo `rustflags` or `rustdocflags` entry can therefore name a plugin artifact without using the already-modeled linker-argument plugin surface. + +Primary references: + +- Rust project. (2026). *Codegen options: linker-plugin-lto*. https://doc.rust-lang.org/rustc/codegen-options/#linker-plugin-lto +- Rust project. (2026). *Linker-plugin-based LTO*. https://doc.rust-lang.org/rustc/linker-plugin-lto.html + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. +- `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the owner for Git-owned Cargo-selected Rust/linker execution and external-input authority. +- Boolean `linker-plugin-lto` enable/disable values do not themselves name a repository-selected plugin artifact and remain permitted by this slice. +- An explicit plugin path is not accepted merely because it is repository-relative. Allowing one requires immutable artifact identity, containment, toolchain compatibility, SBOM/provenance, and exact-head executable evidence in the same reviewed delta. +- Environment `RUSTFLAGS` / `CARGO_ENCODED_RUSTFLAGS`, direct CLI flags, runner/toolchain composition, and plugins selected outside Git-owned Cargo configuration remain CI/runtime or future modeled surfaces. + +## RED + +Commit `f25634aaee2486ae043c410f1589d3d60f511485` adds `tests/test_browser_session_linker_plugin_lto_contract.py`. The initial hostile fixture uses: + +```toml +[build] +rustflags = ["-C", "linker-plugin-lto=tools/review-bypass-llvmgold.so"] +``` + +The predecessor exact `569bfc807df8e4f3f452f04e5dfb865d09086fac` parsed the `-C` option but did not classify `linker-plugin-lto=`, so the canonical Cargo compiler-authority helper did not fail closed. + +## Decision and repair + +Commit `c368afacacf261a84126150d9a06e1271a479246` extends the existing codegen-option classifier instead of adding another Cargo scanner. `_codegen_option_selects_linker_plugin()` distinguishes the documented boolean values (`y`, `yes`, `on`, `true`, `n`, `no`, `off`, `false`) from an explicit value that names a plugin artifact. Path-valued or otherwise unrecognized `linker-plugin-lto=` settings fail closed through the existing `rustflags:codegen linker` / `rustdocflags:codegen linker` authority path. + +Commit `812c80878f8fd68f482d9cf97f5ea4f33ce5d8c7` broadens the focused contract without duplicating production topology: it covers split `-C`, compact `-C...`, long `--codegen=...`, target-level rustflags, and build-level rustdocflags. Bare `linker-plugin-lto` plus documented boolean `yes`/`no` remain control cases and do not name a repository plugin path. + +The repair is deliberately conservative. Bare `linker-plugin-lto` remains allowed because it enables linker-plugin LTO without naming a repository-selected plugin path. Unknown explicit values fail closed rather than being guessed safe. + +## Security effect and residual risk + +This closes the Git-owned Cargo path by which a reviewed Rust source tree could name an explicit LLVM linker-plugin artifact through rustc codegen configuration while avoiding the existing linker `-plugin` checks. + +It does not prove the selected system linker/LTO toolchain trustworthy, validate ambient environment flags, or authorize an explicit plugin artifact. Any future explicit plugin use must arrive with artifact provenance and executable compatibility evidence rather than widening this contract by path alone. diff --git a/docs/traceability/browser-session-linker-positional-input-authority.md b/docs/traceability/browser-session-linker-positional-input-authority.md new file mode 100644 index 000000000..13789acae --- /dev/null +++ b/docs/traceability/browser-session-linker-positional-input-authority.md @@ -0,0 +1,70 @@ +# Browser Session linker positional-input authority + +## Problem + +The Browser Session Cargo compiler-authority contract already rejects repository-owned linker replacement, search-path reselection, response files, plugins, explicit GNU linker scripts, native-library `-L` / `-l` widening, rustc `--extern`, and other modeled execution/input-authority surfaces. It initially constrained only positional native inputs recognized by artifact suffix or repository/path shape, leaving a bare extensionless positional filename as a distinct implicit-script/native-input path. + +The rustc book states that `link-arg` appends one extra argument to the linker invocation and `link-args` appends multiple arguments. On Unix-like targets rustc commonly invokes a C compiler such as `cc` or `clang` as the linker driver. GNU ld documents non-option arguments as object files or archives to be linked into the output and states that an input whose format is not recognized can be parsed as an implicit linker script. Consequently, Git-owned Cargo configuration could inject a reviewed-tree-external native input or script while leaving `Cargo.toml`, canonical production-source topology, nominal linker selection, and `-L` / `-l` settings unchanged. + +This is a provenance gap: an input token alone does not establish the artifact's source revision, producer, digest, target/toolchain identity, reproducibility, or review ancestry. + +## Constraint + +`tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. The Cargo compiler-authority contract consumes that topology and may constrain repository-owned linker input authority, but it must not implement a second Cargo workspace/package resolver. + +This slice covers positional inputs supplied from Git-owned `.cargo/config.toml` / `.cargo/config` flags for the currently modeled direct and GNU-compatible forwarding grammar. It does not claim to parse every non-GNU linker grammar or every option with a separate operand. + +## RED → repair + +RED `e547203368da2aec62e2c94ab491eb0749d7d7b5` adds hostile cases for: + +- direct `-C link-arg=tools/review-bypass-object.o`; +- target-scoped `-C link-args=tools/review-bypass-archive.a`; and +- compiler-driver forwarding through `-Wl,tools/review-bypass-object.o`, `--for-linker=tools/review-bypass-object.o`, and `-Xlinker tools/review-bypass-object.o`. + +The predecessor exact `61053c9cc3ca58f5d812f3ab64660f4e662afdce` allowed these forms because its external-input classifier covered `-L`, `-l`, `--extern`, scripts/plugins/response files, and executable reselection but not positional native artifacts. + +Repair `e73d221cf28aa25ae6fbd941db95d5d923c7e883` keeps canonical Cargo topology ownership unchanged and extends the shared linker-argument classifier with modeled positional-native-input detection for common object/archive artifact forms (`.o`, `.obj`, `.lo`, `.a`, `.lib`, `.rlib`, `.so` including versioned `.so.*`, `.dylib`, `.bc`, and `.res`) whether direct or passed through the already modeled linker-forwarding forms. + +A subsequent hostile fixture `8f5e49f5fe63d99773d25f13a3ab50648e02ac92` proved that suffix-only classification still admitted an extensionless repository/path-shaped input such as `tools/review-bypass-input`. Repair `cb95d5d37050bb7da70f1782da2e63a9b4583734` closed that path-shaped form without duplicating Cargo topology ownership. + +The remaining bare-filename gap was then preserved by RED `ed86b335b4aa6cde223fe2e614bb26d39f789d8a`: `review-bypass-input` could still enter through direct `link-arg` and the modeled `-Wl,`, `--for-linker=`, or repeated `-Xlinker` forwarding forms. Repair `5f070bf0b87ae513cf06badda29914e579f850ee` replaces path/suffix heuristics with an arity-aware direct-linker parser. Every unconsumed non-option token now fails closed as positional input authority. The parser consumes only option operands whose harmless semantics are explicitly modeled; today the narrow operand allowlist includes GNU `-z `, preserving controls such as `-z relro`, `-Wl,-z,relro`, `--for-linker=-z,relro`, and `-Xlinker -z -Xlinker relro`. + +Direct arguments and GNU-compatible forwarding forms consume the same shared classifier. This is not a blanket ban on `link-arg` / `link-args`; option-only controls such as `-Wl,--as-needed` remain allowed. + +## Decision + +Repository-owned Cargo flags fail closed when the currently modeled direct/GNU-compatible linker grammar introduces any unconsumed non-option positional input. A future allowlist must bind the artifact or script to exact digest, producer/source revision, target triple, linker/compiler toolchain identity, build configuration, transitive input closure, reproducibility/provenance evidence, and the consuming exact tree. File extension, path shape, or bare filename is never approval. + +## Security effect + +The repair prevents repository configuration from inserting prebuilt native objects, archives, extensionless files, or GNU implicit scripts while leaving the Rust source closure and nominal linker selection apparently unchanged. Direct and forwarded variants are classified by the same authority code, so `-Wl`, `--for-linker`, and `-Xlinker` do not create parallel provenance policy. + +## Residual surfaces + +The following remain separate review surfaces and are not pre-authorized by this decision: + +- non-GNU linker/control-file grammars and currently unmodeled option-arity/input mechanisms; +- environment `RUSTFLAGS` / `CARGO_ENCODED_RUSTFLAGS` and direct `cargo rustc -- ...` trailing flags; +- rustup/sysroot/toolchain composition, custom target specifications, and target-specific external toolchain scripts; +- runtime-derived or toolchain-internal inputs that cannot be established from Git-owned Cargo argument syntax alone. + +Path-shaped and bare extensionless positional inputs in the currently modeled direct/GNU-compatible forms are no longer residual gaps; they are fail-closed by the shared arity-aware classifier. + +## Primary evidence + +Rust Project. (2026). *Codegen options: `link-arg` and `link-args`*. The rustc book. https://doc.rust-lang.org/rustc/codegen-options/ + +The rustc documentation states that `link-arg` appends a single argument and `link-args` appends multiple arguments to the linker invocation. It also explains that Unix-like targets commonly use `cc` or `clang` as the linker driver. + +Free Software Foundation. (2026). *Using ld: Command-line options*. GNU Binutils documentation. https://sourceware.org/binutils/docs/ld/Options.html + +GNU ld documents non-option command-line arguments as object files or archives to be linked together and documents option/operand grammar constraints relevant to positional-input parsing. + +Free Software Foundation. (2026). *Implicit linker scripts*. GNU Binutils documentation. https://sourceware.org/binutils/docs/ld/Implicit-Linker-Scripts.html + +GNU ld documents that an input file whose format is not recognized can be interpreted as a linker script. This is why all unconsumed non-option tokens in the modeled grammar are provenance-bearing inputs rather than only tokens with familiar native-object suffixes. + +## Verification state + +The RED→repair generations are structurally present on the active #317 lineage. This dossier does not claim hosted executable GREEN or independent review of the newest exact head. Those remain required after lineage reconciliation and exact-head workflow execution before merge or release readiness. diff --git a/docs/traceability/browser-session-linker-runtime-audit-authority.md b/docs/traceability/browser-session-linker-runtime-audit-authority.md new file mode 100644 index 000000000..8366e6a92 --- /dev/null +++ b/docs/traceability/browser-session-linker-runtime-audit-authority.md @@ -0,0 +1,60 @@ +# Browser Session linker runtime-audit authority + +## Problem + +OriginWeave treats repository-selected linker inputs and executable/runtime authority as Browser Session provenance. The shared Cargo/rustc/rustdoc authority contract already rejects linker replacement, plugins, scripts, response files, external native inputs, symbol-policy files, and ELF interpreter selection. GNU-compatible rtld-audit controls remained a separate option-shaped gap: Git-owned linker forwarding could name an audit library that the platform dynamic linker may load when the produced ELF object executes. + +GNU `ld` 2.47 documents `--audit AUDITLIB` as adding `AUDITLIB` to the `DT_AUDIT` entry of the dynamic section, and `--depaudit AUDITLIB` / `-P AUDITLIB` as adding it to `DT_DEPAUDIT`. The linker does not check that these named libraries exist. On platforms supporting the rtld-audit interface, the values therefore select runtime code outside the reviewed Rust/Cargo source closure rather than merely changing a link-time diagnostic or optimization. GNU `ld` also documents that multi-letter options can use one or two leading dashes, so the single-dash `-audit=...` / `-depaudit=...` spellings must not become a spelling-based bypass. + +Rust's `-C link-arg` and `-C link-args` append arguments to the linker invocation. On Unix-like targets using a compiler driver, rustc documents `-Clink-arg=-Wl,$ARG` for forwarding an option to the underlying linker. Repository-owned Cargo `rustflags`, `rustdocflags`, and rustdoc `--doctest-build-arg` can therefore carry `DT_AUDIT` / `DT_DEPAUDIT` selection into produced artifacts. + +## Constraint + +The repair must remain inside the existing Browser Session compiler/linker authority owner. `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology, while `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for repository-selected compiler/rustdoc/toolchain/linker execution and input authority. A second Cargo/linker scanner is not acceptable. + +A pathname allowlist is also insufficient: a permitted soname or path alone does not prove the audit library's immutable bytes, producer provenance, ABI compatibility, deployment-time resolution, dependency closure, or rollback identity. + +## Alternatives considered + +1. Allow `--audit` / `--depaudit` because the linker does not itself load the named library. Rejected: the option persists runtime-load authority in the ELF dynamic section, so the security boundary is the produced runtime behavior, not only the linker process. +2. Permit repository-relative audit-library names. Rejected: name spelling does not prove immutable runtime artifact identity or deployment resolution. +3. Block only the double-dash forms. Rejected: GNU `ld` accepts multi-letter options with one or two leading dashes, and Solaris-compatible `-P` is a documented dependency-audit selector. +4. Extend the existing direct-linker classifier. Selected because build/target `rustflags`, `rustdocflags`, `-Wl,`, `--for-linker=`, `-Xlinker`, `link-arg`/`link-args`, and doctest compiler forwarding already converge on that owner. + +## RED → repair + +Structural RED: + +- `81b05158621716fdb323b8a77960d84eeb6e633e` adds hostile `--audit=...`, `--depaudit=...`, compact `-P...`, and doctest-forwarded audit-library cases while retaining `--as-needed` as an allowed control. +- `4b25e4e64e437a4305bd83308a58c412e0d93baa` adds the GNU single-dash multi-letter `-audit=...` alias so the contract does not accidentally depend on one spelling. + +Minimal repair: + +- `942c158d7b9f7f192688d152673b0badfd36eaf7` adds `_linker_option_selects_runtime_audit_library()` to the existing direct-linker authority classifier and consumes it from `_direct_linker_arguments_extend_authority()`. It covers split and joined double-dash forms, the GNU single-dash multi-letter aliases, and split/compact `-P`. The final-RED → repair diff is one canonical authority file with 10 additions and no deletions. + +No new Cargo topology or linker scanner is introduced. + +## Invariant + +Repository-owned Cargo configuration must not persist an unreviewed rtld-audit library into `DT_AUDIT` or `DT_DEPAUDIT` through Rust/rustdoc linker forwarding. A successful link or command acknowledgement is not evidence that the produced executable's runtime audit code is approved. + +## Evidence required for a future exception + +Any intentional runtime-audit contract must bind, at minimum: + +- immutable audit-library artifact identity and cryptographic digest; +- producer/source provenance, dependency closure, and SBOM linkage; +- exact target ABI, libc/dynamic-linker, and toolchain compatibility; +- build- and deployment-time path/soname resolution and symlink containment; +- purpose and least-privilege justification for audit callbacks; +- reproducible build and runtime acceptance evidence against the pinned loader/library combination; +- rollback, expiry, and revalidation policy; +- buyer-visible security and operability documentation when rtld-audit is enabled. + +Environment/direct-CLI overrides, externally materialized audit libraries, runner image/toolchain identity, system linker distribution, and deployment filesystem state remain CI/release supply-chain evidence surfaces rather than leaf-source exceptions. + +## References + +Free Software Foundation. (2026). *LD: The GNU linker (GNU Binutils 2.47)*. https://sourceware.org/binutils/docs/ld.html + +The Rust Project Developers. (2026). *Codegen options: `link-arg`, `link-args`, and `linker`*. The rustc book. https://doc.rust-lang.org/rustc/codegen-options/ diff --git a/docs/traceability/browser-session-linker-runtime-filter-authority.md b/docs/traceability/browser-session-linker-runtime-filter-authority.md new file mode 100644 index 000000000..49c2bb5fd --- /dev/null +++ b/docs/traceability/browser-session-linker-runtime-filter-authority.md @@ -0,0 +1,67 @@ +# Browser Session linker runtime filter authority + +Status: Proposed + +Owner: OriginWeave Browser Session / Cargo compiler authority contract + +## Problem + +GNU-compatible ELF linkers can persist runtime implementation indirection in the output artifact without adding a normal Cargo dependency. `-f name` / `--auxiliary=name` creates `DT_AUXILIARY`; `-F name` / `--filter=name` creates `DT_FILTER`. At runtime the dynamic linker can resolve symbols through the named shared object, so repository-owned `rustflags` or `rustdocflags` can select runtime code outside the reviewed Cargo source/dependency closure. + +This is not equivalent to ordinary linker tuning. The selected shared object participates in runtime symbol implementation authority. + +## Primary sources + +- GNU Binutils `ld` manual, command-line options: https://sourceware.org/binutils/docs/ld/Options.html +- GNU Binutils `ld` manual: https://sourceware.org/binutils/docs/ld/ +- rustc code-generation options (`link-arg`, `link-args`): https://doc.rust-lang.org/rustc/codegen-options/index.html + +The GNU `ld` manual states that `--auxiliary=name` records `DT_AUXILIARY` and permits the named shared object to provide alternative implementations. `--filter=name` records `DT_FILTER`; when the filter object is used at runtime, the dynamic linker resolves selected symbols to definitions in the named shared object. The same manual specifies that multi-letter options accept one or two leading dashes and that single-letter option operands may be joined to the option. + +The same option surface also defines `-fini=name`, which only selects the symbol used for `DT_FINI`. It does not name an external shared object. Compact `-f` handling therefore must not collapse the documented `-fini=` spelling into auxiliary-library authority. + +## Invariant + +Git-owned Cargo configuration must not use compiler/linker forwarding to select ELF auxiliary/filter runtime implementations unless the selected runtime artifact is represented by an explicit reviewed provenance contract. + +The canonical classifier therefore rejects: + +- `-f name` and compact `-f`; +- `-F name` and compact `-F`; +- `--auxiliary name`, `--auxiliary=name`, and GNU single-dash `-auxiliary` forms; +- `--filter name`, `--filter=name`, and GNU single-dash `-filter` forms; +- the same controls when forwarded through `-Wl,`, `--for-linker=`, `-Xlinker`, rustdoc flags, or rustdoc doctest compiler forwarding. + +`-fini=` and `--as-needed` remain allowed controls because neither names a new runtime implementation artifact. + +## RED → repair evidence + +Structural security RED: `0168d405a89a5dfec5fff4ed49aef28f43f546b8` + +The security RED fixture covers build and target `rustflags`, `rustdocflags`, doctest forwarding, GNU single-dash long-option spelling, compact `-f`/`-F` spelling, and an unrelated allowed control. + +Minimal canonical security repair: `b7adc787523741bd35cedf86704133db7d6da9c3` + +The repair changes only `tests/test_browser_session_cargo_compiler_authority_contract.py`: one runtime-filter classifier is added to the existing direct-linker single writer. No second Cargo scanner, source-topology walker, or pathname allowlist is introduced. + +Fresh compatibility review of the GNU primary source found that the initial compact-`-f` predicate also matched the documented `-fini=` option. Compatibility RED `355161387984dc1277fefd1e1bdc2138360953f3` adds `-Wl,-fini=originweave_fini` as an allowed control and therefore fails against the over-broad initial classifier. Minimal correction `28592541f884c08d2f9cb6cfa888508214951115` excludes the documented `-fini=` spelling before compact auxiliary parsing; the hostile auxiliary/filter cases remain unchanged. + +## Rejected alternatives + +A pathname allowlist is insufficient. A stable path does not prove the selected shared object's digest, producer source, toolchain, ABI compatibility, deployment identity, or rollback state. Treating only `--filter`/`--auxiliary` as relevant while allowing compact `-f`/`-F` or GNU single-dash long-option aliases would leave equivalent spellings outside the contract. Conversely, treating every token beginning with `-f` as auxiliary-library selection is too broad because GNU `ld` separately defines `-fini=`. + +## Future exception evidence + +Any approved exception must bind at least: + +- immutable artifact identity and cryptographic digest; +- producer source revision and build provenance; +- target ABI and dynamic-linker compatibility; +- SBOM/provenance linkage to the consuming release; +- deployment path/namespace containment and purpose; +- reproducible fallback or rollback procedure; +- explicit expiry/revalidation conditions. + +## Residual authority + +Ambient `RUSTFLAGS`/`RUSTDOCFLAGS`, direct CLI arguments, linker distribution/version and `PATH`, runner/container image identity, deployed runtime filesystem contents, loader search paths, and externally materialized shared objects remain CI/release supply-chain evidence surfaces. They are not duplicated into the OriginWeave leaf repository policy contract. diff --git a/docs/traceability/browser-session-linker-runtime-loader-authority.md b/docs/traceability/browser-session-linker-runtime-loader-authority.md new file mode 100644 index 000000000..e28040af4 --- /dev/null +++ b/docs/traceability/browser-session-linker-runtime-loader-authority.md @@ -0,0 +1,60 @@ +# Browser Session linker runtime-loader authority + +## Problem + +OriginWeave treats repository-owned Cargo compiler and linker selection as Browser Session provenance because the linked runtime is part of the executable trust boundary. The shared Cargo/rustc/rustdoc authority contract already rejected linker replacement, plugins, scripts, response files, external native inputs, symbol-policy files, and related execution/input selectors, but GNU-compatible ELF runtime-loader controls were not modeled explicitly. + +GNU `ld` 2.47 documents `-Ifile` / `--dynamic-linker=file` as selecting the dynamic linker recorded for a dynamically linked ELF executable. It also documents `--no-dynamic-linker` as suppressing the load-time dynamic-linker request. These options therefore change which load-time interpreter participates in process startup, or whether that interpreter contract exists at all; they are not ordinary optimization or presentation flags. + +Rust's `-C link-arg` and `-C link-args` append arguments to the linker invocation. On Unix-like targets using a compiler driver, rustc documents `-Clink-arg=-Wl,$ARG` as the route for passing an argument to the underlying linker. Git-owned Cargo `rustflags`, `rustdocflags`, and rustdoc `--doctest-build-arg` forwarding can therefore carry the runtime-loader controls into the final link. + +## Constraint + +The Browser Session authority owner must fail closed without creating a second Cargo/linker scanner. Production Cargo package/source topology remains owned by `tests/test_browser_session_trusted_adapter_boundary.py`; repository-selected compiler/rustdoc/toolchain/linker execution and input authority remains owned by `tests/test_browser_session_cargo_compiler_authority_contract.py`. + +A pathname allowlist is insufficient. A permitted path alone does not establish the loader artifact digest, producer provenance, ABI/toolchain compatibility, filesystem containment, reproducibility, or rollback identity. Likewise, treating `--no-dynamic-linker` as harmless would allow a reviewed build to change its load-time execution model without an explicit provenance decision. + +## Alternatives considered + +1. Allow the linker defaults plus arbitrary `--dynamic-linker` paths. Rejected because a repository change could select a different ELF interpreter while the reviewed Rust/Cargo source closure remains unchanged. +2. Permit repository-relative loader paths. Rejected because repository-relative spelling does not prove immutable artifact identity, symlink containment, executable compatibility, or runtime deployment identity. +3. Block only long-form `--dynamic-linker=`. Rejected because GNU `ld` also accepts the short `-Ifile` spelling, and runtime-loader suppression is independently authority-changing. +4. Extend the existing direct-linker classifier. Selected because all build/target rustflags, rustdocflags, `-Wl,`, `--for-linker=`, `-Xlinker`, and doctest compiler forwarding already converge on that owner. + +## RED → repair + +Structural RED commits: + +- `0b68811ed2bd9ff0324853f8b24a7fbcb8abe626` adds hostile coverage for long-form and short-form ELF runtime-loader selection through build/target `rustflags`, build `rustdocflags`, and rustdoc doctest forwarding. +- `132b6e15c8de47e2caa8395843c64c6ba77bf521` adds explicit `--no-dynamic-linker` coverage while preserving `--as-needed` as an allowed control. + +Minimal repair: + +- `0d241f075fc3958838c9d5d5c266357d86efc6d2` adds `_linker_option_controls_runtime_loader()` to the existing direct-linker authority classifier and rejects `-I`, compact `-Ifile`, `--dynamic-linker`, `--dynamic-linker=file`, and `--no-dynamic-linker` through the same shared owner. No second Cargo topology or linker scanner is introduced. + +The repair commit changes only the canonical authority contract by 10 added lines relative to the final RED head; no unrelated file content is removed or rewritten. + +## Invariant + +Repository-owned Cargo configuration must not select or suppress the ELF load-time interpreter through Rust/rustdoc linker forwarding unless a separately reviewed Browser Session provenance contract proves that authority. The command acknowledgement or successful link alone is not evidence that the runtime interpreter is the intended one. + +## Evidence required for a future exception + +Any future intentional custom runtime-loader contract must bind, at minimum: + +- immutable loader artifact identity and cryptographic digest; +- producer/source provenance and SBOM linkage; +- exact target ABI and linker/toolchain compatibility; +- path and symlink containment at build and deployment time; +- deployment/runtime identity proving the recorded interpreter resolves to the reviewed artifact; +- reproducible reference build evidence; +- rollback and expiry/revalidation rules; +- buyer-visible security and operability documentation when the runtime model differs from the platform default. + +Environment or direct-CLI overrides, runner image identity, system linker distribution, external deployment filesystem state, and loader artifacts materialized outside the reviewed repository remain CI/release supply-chain evidence surfaces rather than leaf-source exceptions. + +## References + +Free Software Foundation. (2026). *LD: The GNU linker (GNU Binutils 2.47)*. https://sourceware.org/binutils/docs/ld.html + +The Rust Project Developers. (2026). *Codegen options: `link-arg`, `link-args`, and `linker`*. The rustc book. https://doc.rust-lang.org/rustc/codegen-options/ diff --git a/docs/traceability/browser-session-linker-runtime-search-path-authority.md b/docs/traceability/browser-session-linker-runtime-search-path-authority.md new file mode 100644 index 000000000..3ce267feb --- /dev/null +++ b/docs/traceability/browser-session-linker-runtime-search-path-authority.md @@ -0,0 +1,61 @@ +# Browser Session linker runtime search-path authority + +Status: Proposed + +Owner: OriginWeave Browser Session / Cargo compiler authority contract + +## Problem + +GNU-compatible ELF linkers can change which shared objects participate in the produced program without changing the reviewed Cargo dependency graph. `-rpath=dir` embeds a runtime library search directory in the output and passes it to the runtime linker. `-rpath-link=dir` changes the link-time search order used to locate additional shared libraries required by shared objects already included in the link. The GNU `ld` manual warns that `--rpath-link` can override a search path compiled into a shared library and thereby select a different library than the runtime linker otherwise would have used. + +Repository-owned `rustflags` or `rustdocflags` can forward these controls through compiler-driver linker arguments. That makes search-path selection part of Browser Session build/runtime provenance rather than an ordinary tuning preference. + +## Primary sources + +- GNU Binutils `ld` manual, command-line options: https://sourceware.org/binutils/docs/ld/Options.html +- rustc code-generation options (`link-arg`, `link-args`): https://doc.rust-lang.org/rustc/codegen-options/index.html + +The GNU `ld` manual states that `-rpath` directories are included in the executable and used by the runtime linker, while `-rpath-link` directories are effective only at link time. It also defines one- or two-dash spellings for multi-letter options and permits `=` or separate operands. + +## Invariant + +Git-owned Cargo configuration must not select ELF runtime or link-time shared-library search directories unless those directories and the artifacts they can resolve are represented by an explicit reviewed provenance contract. + +The canonical direct-linker classifier therefore rejects: + +- `-rpath dir`, `-rpath=dir`, `--rpath dir`, and `--rpath=dir`; +- `-rpath-link dir`, `-rpath-link=dir`, `--rpath-link dir`, and `--rpath-link=dir`; +- the same controls when forwarded through `-Wl,`, `--for-linker=`, `-Xlinker`, rustdoc flags, or rustdoc doctest compiler forwarding. + +`--enable-new-dtags` remains an allowed control because it changes the dynamic-tag form used for an already selected runtime path but does not itself select a directory or external shared object. + +## RED → repair evidence + +Structural RED: `12abc14ec3c98caa5662686857409160bf41a02e` + +The RED fixture covers build/target `rustflags`, build/target `rustdocflags`, doctest forwarding, one- and two-dash multi-letter spellings, joined and split operands, and an unrelated allowed control. + +Minimal canonical repair: `a13f803e8d350abddfaec6dc89378fec438f211d` + +The repair changes only `tests/test_browser_session_cargo_compiler_authority_contract.py`: `_linker_option_selects_runtime_search_path()` is added to the existing direct-linker single writer and reused by all existing forwarding paths. No second Cargo scanner, source-topology walker, path allowlist, or runtime loader policy is introduced. + +## Rejected alternatives + +A pathname allowlist is insufficient. A directory name alone does not bind the concrete shared object eventually selected from that directory, its digest, producer source, ABI, deployment state, symlink resolution, or rollback identity. Allowing `-rpath` while blocking only `-L` would also leave runtime resolution authority outside the reviewed closure; allowing `-rpath-link` would leave link-time transitive shared-library selection outside it. + +## Future exception evidence + +Any approved exception must bind at least: + +- the exact allowed directory/namespace and containment rule; +- immutable identities and cryptographic digests for resolvable shared objects; +- producer source revisions and build provenance; +- target ABI, loader/linker compatibility, and SONAME/DT_NEEDED expectations; +- SBOM/provenance linkage to the consuming release; +- deployment and symlink-resolution evidence; +- reproducible fallback or rollback procedure; +- explicit expiry/revalidation conditions. + +## Residual authority + +Ambient `LD_RUN_PATH`, `LD_LIBRARY_PATH`, `/etc/ld.so.conf`, default linker scripts/search directories, loader cache/state, runner/container image identity, linker distribution/version and configuration, direct CLI arguments, deployed runtime filesystem contents, and externally materialized shared objects remain CI/release supply-chain evidence surfaces. They are not duplicated into the OriginWeave leaf repository policy contract. diff --git a/docs/traceability/browser-session-linker-script-provenance.md b/docs/traceability/browser-session-linker-script-provenance.md new file mode 100644 index 000000000..b16be9dc2 --- /dev/null +++ b/docs/traceability/browser-session-linker-script-provenance.md @@ -0,0 +1,49 @@ +# Browser Session GNU linker-script provenance + +Status: Draft source-level traceability for PR #317. This document does not claim hosted exact-head repository/security GREEN. + +## Problem + +The Browser Session Cargo execution-authority contract already fails closed on direct linker selection, GCC driver program-search replacement, opaque response files, dynamically loaded linker plugins, GCC specs files, and GCC driver wrappers. A separate GNU-compatible input-provenance surface remained: linker scripts selected through `-T` / `--script`. + +Rust documents `-C link-arg` and `-C link-args` as arguments appended to the linker invocation. GCC documents `-T script` as selecting a linker script on systems using the GNU linker. GNU ld additionally defines `INPUT(file, ...)` and `GROUP(file, ...)` commands that introduce named files into the link as if they had appeared on the command line. A Git-owned Cargo flag can therefore select a repository or external linker script that adds object/archive inputs outside the reviewed Rust production-source closure without changing the nominal Cargo target or linker executable. + +This is source/input provenance rather than a claim that the script itself is an executable program. It belongs in the same fail-closed compiler/linker authority boundary because the resulting binary can contain code selected by that script. + +## RED + +Commit `43375ef80b1bcf6a0421f900a2f9cbf519741849` adds a hostile repository fixture whose reviewed Cargo configuration selects `tools/review-bypass.ld`; the script contains `INPUT(tools/review-bypass-object.o)`. The fixture covers three encodings consumed by the existing shared parser: + +- driver-level `-Ttools/review-bypass.ld`; +- forwarded GNU ld `-Wl,--script=tools/review-bypass.ld`; +- split `-Xlinker -T -Xlinker tools/review-bypass.ld` inside `link-args`. + +The predecessor classifier did not recognize linker-script selection, so these cases were source-semantic RED. The fixture imports the canonical Browser Session Cargo compiler-authority contract rather than reproducing Cargo workspace/package discovery. + +## Decision and repair + +Commit `8975224e86ea5ef9821d168efeaafa20702ec659` extends only the existing linker-argument classifier. The guard now fails closed on: + +- direct `-T`, joined `-T`, `--script`, and `--script=`; +- the same script-selection options forwarded through `-Wl,` or `--for-linker=`; +- script-selection option tokens passed through `-Xlinker`. + +Existing executable-selection, response-file, specs, wrapper, plugin, and ordinary non-authorizing linker-argument behavior remains in the same parser. A normal `-pthread` control remains allowed. + +The contract deliberately rejects script selection before trying to parse or allowlist script contents. Relaxation requires a same-tree immutable linker-script/input provenance contract that recursively proves every script-selected object/archive/search surface and remains valid for the exact qualified linker implementation. A path-only allowlist is insufficient because GNU ld scripts can introduce additional inputs and search behavior. + +## Boundary and residual risk + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. +- `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the shared Git-owned Cargo compiler/linker authority classifier. +- This repair does not widen the future BiDi adapter allowlist or authorize any linker script, object, archive, or external path. +- Build-script-generated linker arguments remain prohibited by the separate production build-surface contract until generated-source/build provenance is explicitly modeled. +- Non-GNU linker script/control-file mechanisms, implicit linker scripts supplied as ordinary input files, command-line `-L`/library selection, target/toolchain-supplied scripts, and external native dependencies remain separate review surfaces. + +## Primary references + +Free Software Foundation. (2026). *Using the GNU Compiler Collection (GCC): Link options*. https://gcc.gnu.org/onlinedocs/gcc/Link-Options.html + +Free Software Foundation. (2026). *The GNU linker*. https://sourceware.org/binutils/docs/ld.pdf + +Rust Project Developers. (2026). *The rustc book: Codegen options*. https://doc.rust-lang.org/rustc/codegen-options/index.html diff --git a/docs/traceability/browser-session-linker-section-ordering-file-authority.md b/docs/traceability/browser-session-linker-section-ordering-file-authority.md new file mode 100644 index 000000000..576d97604 --- /dev/null +++ b/docs/traceability/browser-session-linker-section-ordering-file-authority.md @@ -0,0 +1,38 @@ +# Browser Session linker section-ordering-file authority + +Status: Proposed until exact-head hosted repository/security checks and independent review complete. + +## Problem + +GNU `ld --section-ordering-file=script` reads an external script using `SECTIONS` syntax and augments the current linker script by mapping input sections to the start of existing output sections. The GNU linker documentation lists it as another way to specify linker scripts. Because GNU multi-letter options accept one or two leading dashes, `-section-ordering-file=script` is an equivalent spelling. + +For OriginWeave this is linker-script provenance authority. A repository-owned Cargo `rustflags` or `rustdocflags` value can otherwise load an unreviewed section-ordering script after Cargo/rustc select the reviewed source/dependency closure and change which input sections are mapped and ordered in the output artifact. + +## Owner boundary + +Browser Session keeps repository-selected compiler/rustdoc/toolchain/linker input authority in `tests/test_browser_session_cargo_compiler_authority_contract.py`. Production package/source topology remains owned by `tests/test_browser_session_trusted_adapter_boundary.py`; this repair does not duplicate that scanner or move dependency-source authority. + +`--section-ordering-file` is classified by the existing linker-script authority helper so the current `-Wl,`, `--for-linker=`, `-Xlinker`, `-C link-arg`, `-C link-args`, build/target `rustflags`, `rustdocflags`, and rustdoc doctest forwarding all converge on one policy path. + +## RED → repair + +- RED `40f244c914f74e177273576612342ba03245fde9` covers build/target `rustflags`, build `rustdocflags`, rustdoc doctest compiler forwarding, GNU double-dash and single-dash forms, and an allowed `-Wl,-z,relro` control. +- Repair `d33b5f7c107cbd552fd7931f9f5e0b434624b609` extends only `_linker_option_selects_script()` in the canonical direct-linker authority contract. No second scanner or package/source-topology owner is introduced. + +## Decision + +Reject repository-selected section-ordering scripts by default. + +A pathname allowlist is insufficient because a path does not prove script content identity, producer, symlink containment, linker compatibility, or reproducibility. Any future exception must bind the script to an immutable digest, reviewed producer/source identity, exact linker/toolchain compatibility, containment, SBOM/provenance evidence, deterministic rebuild evidence, expiry/invalidation rules, and rollback. + +## Residual authority + +Environment/direct-CLI linker flags, compiler-driver defaults, runner filesystem contents, linker distribution/version and `PATH`, externally restored build/cache state, sysroot contents, and externally materialized linker scripts remain CI/release supply-chain evidence surfaces rather than repository-source exceptions. + +## Evidence + +GNU Binutils documents `--section-ordering-file=script` as an external file that uses `SECTIONS` syntax to augment the current linker script and map input sections to output sections. The same options manual documents that multi-letter options accept one or two leading dashes and may use `=`-joined operands. + +### Reference + +Free Software Foundation. (2026). *GNU linker: Command-line options*. GNU Binutils. https://sourceware.org/binutils/docs/ld/Options.html (retrieved September 19, 2026). diff --git a/docs/traceability/browser-session-linker-symbol-policy-input-authority.md b/docs/traceability/browser-session-linker-symbol-policy-input-authority.md new file mode 100644 index 000000000..87f317f5e --- /dev/null +++ b/docs/traceability/browser-session-linker-symbol-policy-input-authority.md @@ -0,0 +1,56 @@ +# Browser Session linker symbol-policy file authority + +Status: Draft + +## Problem + +OriginWeave's Browser Session build-provenance boundary already rejects positional native linker inputs, linker scripts, response files, plugins, custom sysroots, executable selectors, library-search inputs, and GNU `-R` / `--just-symbols` inputs. A separate class of option-shaped file inputs could still bypass that shared direct-linker classifier: + +- `--version-script=` +- `--dynamic-list=` +- `--retain-symbols-file=` +- `--export-dynamic-symbol-list=` + +GNU `ld` consumes each operand as file content that changes symbol visibility, dynamic symbol selection, or retention in the output artifact. Because the path is embedded in an option token, a classifier that only rejects unconsumed positional native inputs does not see it. + +Rust exposes this authority through `-C link-arg` and `-C link-args`; on Unix-like targets using a compiler driver, rustc documents `-C link-arg=-Wl,$ARG` as a way to pass an argument to the actual linker. Git-owned Cargo `rustflags` and `rustdocflags` therefore make these file selectors part of reviewed compiler/linker provenance rather than an ordinary optimization surface. + +## Decision + +`tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for repository-selected Cargo compiler/rustdoc/linker execution and input authority. The existing direct-linker parser now classifies the four GNU symbol-policy file selectors above through one `LINKER_SYMBOL_POLICY_FILE_OPTIONS` set and `_linker_option_selects_symbol_policy_file()` helper. + +The rule is reused by build/target `rustflags`, build/target `rustdocflags`, and rustdoc `--doctest-build-arg` forwarding through the existing `-Wl,`, `--for-linker=`, `-Xlinker`, `link-arg`, and `link-args` paths. No second Cargo topology scanner or cross-service authority was introduced. `tests/test_browser_session_trusted_adapter_boundary.py` remains the production Cargo package/source-closure owner. + +Inline symbol selection without an external file remains allowed. The focused control fixture uses `--export-dynamic-symbol=originweave_*` to distinguish a literal pattern from `--export-dynamic-symbol-list=`. + +## RED → repair evidence + +- RED: `84d3e085c7c7ac5b858b4282c9d53b1c4c94b24d` adds `tests/test_browser_session_linker_symbol_policy_input_authority_contract.py`. Against predecessor `4e8fbcc98835e54c3b7d3465b752a5b0f0e69e33`, all four option-shaped hostile inputs bypassed the shared direct-linker authority predicate. +- Repair: `ba239fab05209d13fba2abdd948924a6c2668d32` extends the existing canonical classifier with the symbol-policy file option set and helper. The repair changes only the shared authority file; the focused test already exercises build `rustflags`, target `rustflags`, build `rustdocflags`, doctest forwarding, and an inline-symbol allowed control. + +A source-semantic focused check on the repaired helper classifies all four hostile selectors as external authority and leaves the inline `--export-dynamic-symbol=originweave_*` control unclassified. Hosted exact-head executable evidence is still required before repository/security GREEN is claimed. + +## Security and provenance effect + +Repository-owned Cargo configuration can no longer select unreviewed symbol-version, dynamic-list, retained-symbol, or export-symbol-list files through the modeled GNU-compatible linker forwarding paths without tripping the Browser Session provenance contract. This prevents link output semantics from depending on an external policy file that is absent from the reviewed Cargo package/source closure. + +The policy is fail closed rather than pathname-allowlist based. A path alone does not prove file digest, producer provenance, symlink containment, exact linker compatibility, reproducibility, or rollback. + +## Residual authority + +The following remain CI/release supply-chain evidence surfaces rather than duplicated OriginWeave leaf ownership: + +- environment- or direct-CLI-injected rustc/rustdoc/linker arguments; +- linker-family-specific symbol/control-file options outside the modeled GNU-compatible grammar; +- ambient compiler/linker binaries, sysroot contents, runner image, and restored build artifacts; +- immutable release evidence tying the effective toolchain, linker, arguments, SBOM, and provenance to the shipped artifact. + +Any future exception for one of these symbol-policy files must bind immutable artifact identity and digest, producer/source provenance, exact toolchain/linker compatibility, purpose, containment, reproducibility, and rollback in the same reviewed delta. + +## References + +Free Software Foundation. (n.d.). *GNU ld: Options*. GNU Binutils documentation. https://sourceware.org/binutils/docs/ld/Options.html + +The Rust Project Developers. (n.d.). *Codegen options: `link-arg` and `link-args`*. The rustc book. https://doc.rust-lang.org/rustc/codegen-options/ + +Retrieved 2026-09-19. diff --git a/docs/traceability/browser-session-linker-sysroot-input-authority.md b/docs/traceability/browser-session-linker-sysroot-input-authority.md new file mode 100644 index 000000000..6418cff46 --- /dev/null +++ b/docs/traceability/browser-session-linker-sysroot-input-authority.md @@ -0,0 +1,49 @@ +# Browser Session linker sysroot input authority traceability + +Status: Draft contract evidence on PR #317. This document does not claim hosted repository/security GREEN. + +## Problem + +The Browser Session Cargo compiler-authority contract already failed closed when Git-owned `rustflags` or `rustdocflags` selected a Rust compiler/rustdoc sysroot with `--sysroot`. A distinct linker surface remained. Rust codegen flags can forward direct linker arguments through `-Wl,`, `--for-linker=`, or `-Xlinker`; the shared direct-linker classifier did not classify GNU/LLD `--sysroot=` as external linker input authority. + +GNU `ld` documents `--sysroot=directory` as replacing the linker's configured sysroot location. LLD documents its ELF linker as a GNU-linker-compatible replacement accepting GNU command-line arguments. A repository-owned forwarded linker sysroot can therefore change where the linker resolves system libraries and related link inputs without changing the reviewed Cargo package/source closure. + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. +- `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for Git-owned Cargo-selected compiler, rustdoc, and linker execution/input authority. +- The fix must cover build/target `rustflags`, build/target `rustdocflags`, and rustdoc doctest compiler forwarding through the existing parser; no second Cargo topology or linker-authority scanner is introduced. +- Unrelated linker hardening remains permitted. In particular, `-Wl,-z,relro` does not select an external input and remains an allowed control. +- Environment-selected flags, direct rustc/rustdoc/linker CLI invocation, runner/toolchain contents, and the identity of default system sysroots remain CI/release supply-chain evidence surfaces. + +## RED + +Commit `1af97ad213eb314c408dd5b0b20b87b044822e72` adds `tests/test_browser_session_linker_sysroot_input_authority_contract.py` with hostile fixtures for: + +- build `rustflags` forwarding `-Wl,--sysroot=...`; +- target `rustflags` forwarding `--for-linker=--sysroot=...`; +- build `rustdocflags` forwarding the equals form through `-Xlinker`; +- rustdoc `--doctest-build-arg` forwarding a linker sysroot; +- an allowed `-Wl,-z,relro` control. + +At the parent exact head, `_direct_linker_arguments_extend_authority()` classified plugins, executable error handlers, DTLTO executable selectors, linker scripts, response files, native library selectors, and positional native inputs, but `--sysroot=` matched none of those branches. The new hostile cases therefore preserve a concrete source-semantic gap rather than broadening policy by assertion. + +## Decision and repair + +Commit `372f319a4c68669a29c10e56e7e726d469e6c318` extends the existing `_linker_argument_extends_external_inputs()` classifier so split or equals `--sysroot` is treated as external linker input authority. The repair is two lines in the canonical owner. Existing `-Wl,`, `--for-linker=`, `-Xlinker`, build, target, rustdoc, profile, and doctest-forwarding paths consume that same classifier. + +The earlier rustc/rustdoc `--sysroot` check remains valid and intentionally redundant at its more specific compiler boundary. The added linker-level classification covers the same spelling only after it has been forwarded into linker authority. + +A path allowlist was rejected. A path string does not prove immutable sysroot contents, system-library identity, symlink containment, producer toolchain, SBOM/provenance, or reproducibility. Any future approved linker sysroot requires versioned immutable artifact identity and release evidence rather than pathname trust. + +## Security and release consequence + +This closes the modeled Git-owned Cargo path in which reviewed Rust source/dependency topology remained unchanged while the linker resolved inputs under a repository-selected sysroot. It does not prove the integrity of the ambient/default linker sysroot, runner image, direct CLI flags, environment variables, or externally restored toolchain state. Those remain release/toolchain provenance requirements and must not be inferred from this source contract. + +Hosted exact-head execution, whole-PR review closure, owned production rustdoc/test/edge coverage, and release acceptance remain separate gates. + +## Primary references + +Free Software Foundation. (2025). *The GNU linker* (GNU Binutils 2.45), `--sysroot=directory`. https://sourceware.org/binutils/docs-2.45/ld.pdf + +LLVM Project. (2026). *LLD - The LLVM linker*. Retrieved September 18, 2026, from https://lld.llvm.org/ diff --git a/docs/traceability/browser-session-lld-cmse-import-library-authority.md b/docs/traceability/browser-session-lld-cmse-import-library-authority.md new file mode 100644 index 000000000..69e815937 --- /dev/null +++ b/docs/traceability/browser-session-lld-cmse-import-library-authority.md @@ -0,0 +1,50 @@ +# Browser Session LLD CMSE import-library authority + +Status: Source repair implemented; hosted proof pending + +## Problem + +LLVM LLD's ELF driver defines `--in-implib` as an ARM CMSE input selector. The option reads an existing CMSE secure-code import library from a previous program revision so LLD can preserve secure gateway entry-function addresses in a new CMSE import library or secure image. + +On the reviewed Browser Session Cargo authority path, the split spelling `--in-implib FILE` is conservatively caught because `FILE` becomes an unconsumed positional linker input. The `EEq` joined spelling `--in-implib=FILE` kept the external artifact path inside the option token and previously bypassed the canonical direct-linker authority predicate. + +This is an input-provenance gap, not a general ARM CMSE ban. `--out-implib=FILE` names an output destination and is not equivalent to the input selector. + +## Primary evidence + +LLVM upstream main at `f8f4816496f6126f371350819d48017d1c330b56` provides the current primary evidence used for this repair: + +- `lld/ELF/Options.td`: `in_implib` is `EEq<"in-implib", ...>` and is documented as reading an existing CMSE secure-code import library and preserving entry-function addresses in the resulting library/image. +- `lld/ELF/Options.td`: `out_implib` is separately documented as outputting the CMSE secure-code import library to a file. +- `lld/ELF/Driver.cpp`: `OPT_in_implib` populates the CMSE input-library argument and is rejected on unsupported targets. +- LLD ARM tests exercise the CMSE input option and its validation rules. + +Upstream references: + +- +- +- + +## Owner boundary + +`tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for Git-owned Cargo compiler/linker input authority. No parallel Cargo topology/config scanner was introduced. + +The repair is consumed through the existing direct-linker classifier, so `rustflags`, `rustdocflags`, and rustdoc doctest compiler forwarding continue to share one authority decision path. + +## RED → repair + +- Structural RED: `411ec418c73b89a0f3923af0a16a214faafe0000` adds `tests/test_browser_session_lld_cmse_import_library_authority_contract.py`. It requires joined `--in-implib=...` to fail closed through the canonical authority helper, exercises rustdoc doctest compiler forwarding, and keeps output-only `--out-implib=...` as an allowed control. +- Minimal causal repair: `28ffd6fc4784b25e2d48f4d16b0de0acc4324c47` adds `_linker_option_selects_cmse_import_library()` and consumes it from `_direct_linker_arguments_extend_authority()`. The repair changes the canonical authority file by six added lines and no deletions; no unrelated rewrite or duplicate scanner was introduced. +- Exact compare from predecessor `65511f47d355a9c68ed669679bc406bd9230ab5f` to repair head is two commits, two files: the 36-line hostile contract plus the six-line canonical classifier repair. + +No pull-request-triggered hosted workflow exists yet for repair head `28ffd6fc4784b25e2d48f4d16b0de0acc4324c47`, so this document does not claim hosted executable GREEN, repository/security GREEN, whole-PR review closure, or 100% quality-gate closure. + +## Rejected alternatives + +- Path allowlists are insufficient: a trusted-looking pathname does not prove the selected import library's content, producer, toolchain compatibility, symlink containment, or reproducibility. +- Blocking every CMSE option would conflate external input authority with output configuration and would reject `--out-implib` without evidence. +- A supplemental scanner would duplicate the canonical direct-linker authority owner. + +## Future exception evidence + +Any future decision to permit repository-selected CMSE input import libraries must prove the selected artifact digest, producer provenance, exact LLD/toolchain compatibility, repository/approved-artifact containment, SBOM/provenance inclusion, reproducibility, and rollback/invalidation behavior in the same reviewed delta. diff --git a/docs/traceability/browser-session-lld-dtlto-execution-authority.md b/docs/traceability/browser-session-lld-dtlto-execution-authority.md new file mode 100644 index 000000000..00f8b6056 --- /dev/null +++ b/docs/traceability/browser-session-lld-dtlto-execution-authority.md @@ -0,0 +1,56 @@ +# Browser Session LLD Distributed ThinLTO execution authority + +## Problem + +Browser Session's Cargo/rustc provenance contract already fails closed on repository-selected linker replacement, linker plugins, linker scripts, response files, error-handler executables, native positional inputs, and external library search inputs. LLVM LLD's Distributed ThinLTO interface adds two more executable-selection surfaces that were not modeled explicitly: + +- `--thinlto-distributor=` selects the file LLD executes as the distributor process. +- `--thinlto-remote-compiler=` selects the compiler that the distributor process invokes for remote backend compilations. + +LLD documents DTLTO as distributing ThinLTO backend compilations through an external distribution system during the traditional link step. The remote compiler must match the LLD version. Repository-owned Cargo `rustflags`, `rustdocflags`, profile rustflags, or rustdoc doctest forwarding can pass these options through rustc `-C link-arg` / `-C link-args`, so the effective build TCB can gain distributor/compiler executables without changing Cargo package topology or the selected linker binary. + +## Constraint and owner boundary + +`tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for repository-selected Cargo/rustc/rustdoc/linker execution and input authority. `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. No DTLTO scanner is copied into downstream Navigation, WebDriver BiDi, EgressWeave, Wardnet, contextual-orchestrator, or other canonical owners. + +Ambient `RUSTFLAGS`/`RUSTDOCFLAGS`, direct CLI options, LLD version/distribution, runner `PATH`, external distributor configuration, remote-worker images/toolchains, and restored caches remain CI/release supply-chain evidence surfaces rather than leaf-source authority. + +## Alternatives considered + +Allowing repository-relative distributor/compiler paths was rejected. Relative containment does not prove executable identity after symlink resolution, mutation between review and execution, the distributor's transitive runtime, or the remote worker/toolchain identity. + +Allowing only Git-tracked executables was rejected. Git tracking does not bind the actual interpreter/binary, remote execution system, remote compiler version, environment, or worker artifact set used at link time. + +Blocking all ThinLTO options was rejected. Numeric/policy controls such as `--thinlto-jobs=` do not themselves select a new executable and need not widen the execution TCB. + +The selected rule is narrow: fail closed when forwarded direct-linker arguments select the DTLTO distributor or remote compiler executable paths. + +## RED → repair + +Structural RED `e86b8f52099a0e04ebf53595f7f2110cfd033fb6` adds realistic Cargo fixtures for build `rustflags`, target `rustflags`, build `rustdocflags`, and rustdoc doctest forwarding. It covers both documented executable selectors while preserving `--thinlto-jobs=2` as an allowed non-executable control. + +Minimal repair `de49c23712ee1defdb73ca440304ede2c5ddf413` adds the DTLTO executable selector to the existing direct-linker classifier. The existing `-Wl,`, `--for-linker=`, `-Xlinker`, build, target, profile, rustdoc, and doctest-forwarding paths consume the same classifier; no second Cargo topology/config scanner was introduced. + +The initial fixture path/helper spelling used `dtlt`. Naming-only successors `d4df3c86079297018387a2be17685a0fe5f67923`, `1ce0ebaca2c22d9a2eb9abccd48a8e23909ba5cb`, and `69ccddcc77195fc9e66edf93a82eaaf90735e05f` normalize the test path, test method names, and shared helper to the canonical `DTLTO` acronym without changing policy semantics. + +## Security and release consequence + +A future DTLTO exception requires evidence stronger than a path allowlist: + +- immutable distributor and remote-compiler artifact identities and digests; +- exact LLD/LLVM/compiler version compatibility; +- remote worker image/runtime identity and isolation boundary; +- distributor arguments and transitive executable/tool inputs; +- input/output transfer semantics and integrity checks for remote compilation; +- environment/credential exposure analysis; +- SBOM and provenance linking each remote backend result to the consuming binary; +- deterministic or independently reproducible reference evidence where applicable; +- failure recovery, rollback, cache invalidation, and expiry/removal conditions. + +Until that contract exists, repository-selected DTLTO distributor and remote-compiler executable paths fail closed. + +## Primary source + +LLVM Project. (2026). *Integrated Distributed ThinLTO (DTLTO) — lld 24.0.0git documentation*. https://lld.llvm.org/DTLTO.html + +The documentation states that `--thinlto-distributor=` specifies the file to execute as the distributor process and `--thinlto-remote-compiler=` specifies the compiler the distributor invokes; the compiler must match the LLD version. It also warns that options introducing extra input/output files can cause miscompilation if the distribution system does not correctly transfer them. diff --git a/docs/traceability/browser-session-lld-dtlto-subprocess-argument-authority.md b/docs/traceability/browser-session-lld-dtlto-subprocess-argument-authority.md new file mode 100644 index 000000000..030ed0899 --- /dev/null +++ b/docs/traceability/browser-session-lld-dtlto-subprocess-argument-authority.md @@ -0,0 +1,43 @@ +# Browser Session LLD DTLTO subprocess argument authority + +## Problem + +OriginWeave treats repository-owned Rust/Cargo linker selection as Browser Session supply-chain authority. LLVM LLD Distributed ThinLTO (DTLTO) can execute a distributor and a remote compiler, and it also exposes options that forward arbitrary command-line arguments into those subprocesses. + +At LLVM `llvm-project@0da016867d1fd3d7938895ec36a9775fe26e1919`, `lld/ELF/Options.td` defines `--thinlto-distributor-arg`, `--thinlto-remote-compiler-prepend-arg`, and `--thinlto-remote-compiler-arg` as two-dash `EEq` options, so both separated and `=`-joined forms are accepted. `lld/docs/DTLTO.md` states that these values are placed on the distributor or remote compiler command line. The upstream ELF DTLTO tests use `--thinlto-distributor-arg` for a Python script path and exercise remote-compiler arguments directly. + +Before this repair, the canonical Browser Session compiler/linker authority classifier rejected `--thinlto-distributor=` and `--thinlto-remote-compiler=`, but did not classify their argument-forwarding surfaces. An `=`-joined argument remained inside one linker option token, so the positional-native-input fallback could not observe its payload. A separated forwarded argument that itself began with an otherwise-unclassified option could also escape positional-input detection. + +That is execution and input provenance authority, not a harmless linker tuning surface. A forwarded argument can alter the subprocess toolchain, load executable compiler plugins, select target/runtime inputs, or otherwise change the native bytes emitted for the Browser Session artifact. + +## Boundary and ownership + +`tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for repository-selected compiler, rustdoc, linker execution, and linker-input authority. `tests/test_browser_session_trusted_adapter_boundary.py` continues to own production Cargo package/source topology. No DTLTO scanner or policy authority is duplicated in another bounded context. + +The repair deliberately extends the existing `_linker_option_selects_dtlto_executable()` classifier rather than adding a parallel parser. Its name is retained to avoid needless call-site churn; its docstring now states the broader invariant: DTLTO options that select **or control** a subprocess extend authority. + +## RED → repair + +- Initial structural RED `fe842827a2781b198075d91352745fd90b3b341a` adds joined distributor/remote-compiler argument cases, rustdoc doctest forwarding, and a typed `-z relro` negative control. +- Structural RED successor `352c22b3b7aba5888e2ffd6f1e56cad93e04dba4` adds the separated `--thinlto-remote-compiler-arg --target=...` spelling so the `EEq` grammar itself is covered rather than only the joined form. +- Minimal causal repair `bb657a7be0149d6c47207723cdd9650fb2e0508d` changes only the canonical DTLTO classifier: separated argument-option names fail closed and joined distributor/compiler selectors plus all three argument-forwarding prefixes fail closed. The repair commit changes one existing file by `+16/-2`; no unrelated production or test topology is rewritten. + +A preliminary `--chroot` probe (`8634074f4ebad22a17fddcc4badef8176a51c1a4`) was rejected as a false finding and removed by `2d7c6e653d4cf0c7dad81c2827a3b3686b2ec28f`: current LLD accepts `--chroot` only as a separated option, and OriginWeave's existing positional-native-input fallback already rejects the following path. It is not part of this repair claim. + +## Alternatives rejected + +Allowing known argument strings was rejected because DTLTO arguments are an open-ended subprocess command-line surface; string allowlisting would not prove the selected compiler/distributor implementation, plugin bytes, target/sysroot contents, or transitive files opened by that subprocess. + +Inspecting only the DTLTO executable path was rejected because a fixed executable with mutable or unreviewed arguments can still change code generation and load additional executable code. + +Treating every unknown linker option as hostile was also rejected. The existing parser intentionally distinguishes typed linker controls such as `-z relro` from execution/input authority so the boundary remains precise instead of becoming a blanket option ban. + +## Evidence required for a future exception + +A future DTLTO exception must be owned by CI/release provenance rather than by an ad-hoc source pathname. Evidence must bind the exact distributor and remote-compiler artifact digests, version/toolchain identity, complete forwarded argv, target/sysroot and plugin inputs, working-directory and environment inputs that affect code generation, architecture/ABI, and any files materialized or consumed by distributed backends. It must also provide SBOM/provenance linkage, independent reproducibility or an equivalent deterministic attestation, and explicit cache/invalidation/rollback behavior. + +Repository pathname containment by itself is insufficient because it does not prove the bytes executed or the transitive inputs selected by forwarded subprocess arguments. + +## Acceptance status + +The source-semantic RED → minimal repair lineage is established at the commits above. This document does not claim hosted repository/security GREEN, whole-PR review closure, or release readiness. Those claims require exact-head hosted checks and current-head review after the final reconciled #317 lineage is produced. diff --git a/docs/traceability/browser-session-lld-error-handler-authority.md b/docs/traceability/browser-session-lld-error-handler-authority.md new file mode 100644 index 000000000..196d295e7 --- /dev/null +++ b/docs/traceability/browser-session-lld-error-handler-authority.md @@ -0,0 +1,52 @@ +# Browser Session LLD error-handler execution authority + +## Problem + +Browser Session's Cargo/rustc provenance contract already rejects linker replacement, linker plugins, linker scripts, response files, native positional inputs, and external library search inputs. It did not classify LLVM LLD's `--error-handling-script=` option as linker execution authority. + +LLD documents `--error-handling-script=` as a user-provided executable that is invoked from linker error handling. The script may be resolved through `PATH` or supplied as a full path, must be executable, and runs in the same environment as the parent linker process. A repository-owned Cargo `rustflags` or `rustdocflags` value can forward this option through rustc's `-C link-arg` / `-C link-args` path. That creates a code-execution edge outside the reviewed Browser Session source and dependency closure even when the selected linker binary itself is unchanged. + +This matters operationally because the handler is conditional: a normal link can appear inert while a missing library or undefined symbol causes the linker to execute the selected program. Command acknowledgement or a successful configuration parse therefore cannot be treated as evidence that the build TCB stayed unchanged. + +## Constraint and owner boundary + +`tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for repository-selected Cargo/rustc/rustdoc/linker execution and input authority. `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. No linker-policy scanner is duplicated into downstream Browser Session, Navigation, WebDriver BiDi, EgressWeave, Wardnet, or contextual-orchestrator owners. + +Ambient `RUSTFLAGS`, `RUSTDOCFLAGS`, direct CLI options, runner `PATH`, the concrete linker distribution/version, and externally restored toolchain/cache state remain CI/release supply-chain evidence surfaces rather than leaf-source authority. + +## Alternatives considered + +Allowing repository-relative error-handler paths was rejected. Relative path containment says nothing about executable identity after symlink resolution, producer provenance, permissions, mutation between review and execution, or the environment inherited by the process. + +Allowing the option only when the file is tracked by Git was rejected. Git tracking alone does not bind the executable artifact, interpreter, transitive runtime, or runner environment used at link time. + +Blocking all linker policy options was rejected because modeled non-executable policy such as `--as-needed` does not itself select another executable or external input and remains useful without widening the build TCB. + +The selected rule is narrow: fail closed only when forwarded linker arguments select LLD's error-handler executable surface. + +## RED → repair + +Structural RED `a59f8727061aa1cd5e6d136f61f6e8d969164d8b` adds realistic Cargo fixtures for build `rustflags`, target `rustflags`, build `rustdocflags`, and rustdoc doctest forwarding. It covers both `--error-handling-script=` and split `--error-handling-script,` forms behind `-Wl,`. `--as-needed` remains an allowed control. + +Minimal repair `df9c0257982ed136403ce8c3b36999563209ebb0` adds `_linker_option_selects_error_handler()` to the existing direct-linker classifier and reuses the existing build, target, profile, rustdoc, doctest, `-Wl,`, `--for-linker=`, and `-Xlinker` paths. No Cargo topology/config scanner was added. + +## Security and release consequence + +A future exception requires evidence stronger than a path allowlist: + +- immutable executable identity and digest; +- exact LLD/toolchain and runner identity; +- interpreter and transitive runtime provenance when the handler is a script; +- purpose and trigger conditions for each supported error tag; +- environment and secret-exposure analysis; +- SBOM/provenance linkage to the consuming binary; +- reproducible no-handler reference build where applicable; +- rollback and expiry/removal conditions. + +Until such a contract exists, repository-selected LLD error handlers fail closed. + +## Primary source + +LLVM Project. (2026). *Error Handling Script — lld 24.0.0git documentation*. https://lld.llvm.org/error_handling_script.html + +The documentation states that LLD executes the user-provided error-handling script in the same environment as the parent process and currently defines `missing-lib` and `undefined-symbol` trigger tags. diff --git a/docs/traceability/browser-session-lld-layout-profile-input-authority.md b/docs/traceability/browser-session-lld-layout-profile-input-authority.md new file mode 100644 index 000000000..f8d2930f0 --- /dev/null +++ b/docs/traceability/browser-session-lld-layout-profile-input-authority.md @@ -0,0 +1,67 @@ +# Browser Session LLD layout/profile input authority + +Status: Proposed until exact-head hosted repository/security checks and independent review complete. + +## Problem + +LLD can read repository-selected external files after Cargo/rustc have already selected the reviewed Rust source and dependency closure. Five ELF linker option families materially affect output layout or LTO decisions: + +- `--call-graph-ordering-file=` lays out sections using a supplied call graph; +- `--irpgo-profile=` reads a temporary IRPGO profile for startup/profile-guided ordering; +- `--symbol-ordering-file=` lays out sections according to a supplied symbol-order file; +- `--lto-sample-profile=` reads an LTO sample profile. LLD also exposes the GNU-plugin-compatible `-plugin-opt=sample-profile=` / `--plugin-opt=sample-profile=` alias; +- `--lto-cs-profile-file=` selects a context-sensitive PGO profile for LTO. LLD also exposes `-plugin-opt=cs-profile-path=` / `--plugin-opt=cs-profile-path=` as aliases. + +Before these repairs, equals-joined spellings such as `--symbol-ordering-file=tools/symbols.order` and `--lto-cs-profile-file=tools/context-sensitive.profdata` were not classified by the direct-linker authority contract. Because the file name remains inside the same option token, the existing positional-native-input fallback never saw a separate path token. Git-owned Cargo `rustflags`, `rustdocflags`, or rustdoc doctest compiler forwarding could therefore select unreviewed layout/profile material without tripping the Browser Session provenance boundary. + +## Authoritative option grammar + +LLVM LLD's ELF option table distinguishes the accepted spellings: + +- `call-graph-ordering-file` uses the `Eq` multiclass. `Eq` accepts both one- and two-dash multi-letter spellings and supports separated and `=`-joined operands. Therefore both `-call-graph-ordering-file=` and `--call-graph-ordering-file=` are modeled. +- `irpgo-profile` and `symbol-ordering-file` use `EEq`, whose spelling is double-dash only and supports separated and `=`-joined operands. +- `lto-sample-profile=` uses `JJ`, a double-dash joined option. +- `plugin-opt=sample-profile=` is an alias of `lto-sample-profile` using `J`; `J` accepts both one- and two-dash spellings. +- `lto-cs-profile-file=` uses `JJ`, so the direct spelling is `--lto-cs-profile-file=`. +- `plugin-opt=cs-profile-path=` aliases `lto-cs-profile-file` through `J`, so both `-plugin-opt=cs-profile-path=` and `--plugin-opt=cs-profile-path=` are accepted. + +The contract does not invent single-dash aliases for the `EEq` or `JJ` options. + +## Owner boundary + +Browser Session keeps repository-selected compiler/rustdoc/toolchain/linker execution and input authority in `tests/test_browser_session_cargo_compiler_authority_contract.py`. Production package/source topology and dependency-source authority remain owned by `tests/test_browser_session_trusted_adapter_boundary.py`. + +This repair extends the existing direct-linker classifier only. Existing `-Wl,`, `--for-linker=`, `-Xlinker`, `-C link-arg`, `-C link-args`, build/target `rustflags`, build/target `rustdocflags`, and rustdoc doctest forwarding continue to converge on the same owner path. + +## RED → repair + +- RED `04a6079c025d7a1db83c8ee302f957f9fcc93096`: introduces hostile equals-joined LLD layout/profile file cases plus rustdoc doctest forwarding and an allowed `-Wl,-z,relro` control. +- Repair `04da16344e413cdd966d6104f75c27b5603750ea`: adds the four original file-selecting option families to the canonical direct-linker authority classifier. +- Grammar correction RED `565456357d5b7f1a52f5b58d8205f170d4bb39ff`: covers the valid single-dash `-call-graph-ordering-file=` spelling. +- Grammar correction repair `a5c63c7fbe0cef14ee47cd184b87d001c8b296b1`: adds that exact alias without broadening unrelated option matching. +- Alias RED `3797481cdcd241c94dc1bfe5bad0d32d54be0957`: covers both `-plugin-opt=sample-profile=` and `--plugin-opt=sample-profile=`. +- Alias repair `d6dc93f87afb986be88537a8f7a174126c5656e5`: routes those LLD sample-profile aliases through the same layout/profile input classifier. +- Context-sensitive profile RED `9b23b72fe3b1649983bae8eb1269bdb64a6871c2`: extends the existing hostile contract with `--lto-cs-profile-file=`, `--plugin-opt=cs-profile-path=`, `-plugin-opt=cs-profile-path=`, and rustdoc doctest forwarding while keeping `-Wl,-z,relro` as the allowed control. +- Context-sensitive profile repair `ea4cfea24b654178360ac029f6ae61f6b8f6c7eb`: adds exactly those three accepted option prefixes to the existing layout/profile classifier. The predecessor-to-repair diff is one canonical authority file, `+3/-0`. + +These are source-semantic RED/repair contracts. They are not hosted executable GREEN until the exact protected evidence lanes actually run. + +## Decision + +Repository-selected LLD layout/profile files are rejected by default because they can alter code/data placement or LTO decisions while living outside the reviewed Cargo source/dependency closure. + +A pathname allowlist is insufficient. A future exception must bind the input to an immutable content digest, reviewed producer/source identity, exact linker/toolchain compatibility, repository/runner containment including symlink resolution, SBOM/provenance evidence, deterministic rebuild evidence, expiry/invalidation rules, and rollback. Profile data that may encode production execution behavior also requires purpose and data-retention review before becoming a governed build input. + +## Residual authority + +Environment/direct-CLI linker arguments, compiler-driver defaults, toolchain-distributed profiles, runner filesystem contents, externally restored build/cache state, linker distribution/version and `PATH`, and artifacts materialized outside Git remain CI/release supply-chain evidence surfaces. They are not converted into repository-source exceptions by this contract. + +## Evidence + +LLVM's ELF `Options.td` defines `call-graph-ordering-file`, `irpgo-profile`, `symbol-ordering-file`, `lto-sample-profile`, `lto-cs-profile-file`, the `plugin-opt=sample-profile=` and `plugin-opt=cs-profile-path=` aliases, and the `Eq`/`EEq`/`J`/`JJ` spelling grammar. `lld/ELF/DriverUtils.cpp` treats these option values as paths when generating reproduction material, corroborating that the layout/profile values are external file inputs rather than scalar tuning values. + +### References + +LLVM Project. (2026). *LLD ELF option definitions* (`lld/ELF/Options.td`, commit `40a6a441e7a945ca964619cfa2c328120cb9dae5`). https://github.com/llvm/llvm-project/blob/40a6a441e7a945ca964619cfa2c328120cb9dae5/lld/ELF/Options.td (retrieved September 19, 2026). + +LLVM Project. (2026). *LLD ELF driver reproduction utilities* (`lld/ELF/DriverUtils.cpp`, commit `34eeb2320ff2b991ddb3e3511bbe01ba14478d93`). https://github.com/llvm/llvm-project/blob/34eeb2320ff2b991ddb3e3511bbe01ba14478d93/lld/ELF/DriverUtils.cpp (retrieved September 19, 2026). diff --git a/docs/traceability/browser-session-lld-mllvm-authority.md b/docs/traceability/browser-session-lld-mllvm-authority.md new file mode 100644 index 000000000..d6abce954 --- /dev/null +++ b/docs/traceability/browser-session-lld-mllvm-authority.md @@ -0,0 +1,65 @@ +# Browser Session LLD `--mllvm` authority + +Status: source-semantic repair on PR #317; hosted executable evidence is still required before GREEN. + +## Problem + +OriginWeave already fails closed when Git-owned Cargo `rustflags` or `rustdocflags` use rustc `-C/--codegen llvm-args=...`. The same trust boundary was incomplete at the ELF linker layer: LLD defines `mllvm` as an option that forwards additional arguments directly to LLVM option processing. Repository-owned Cargo flags could therefore route opaque LLVM options through rustc linker forwarding even though the typed rustc LLVM-option path was rejected. + +The relevant Browser Session boundary is provenance, not whether a particular LLVM option is currently known to be harmful. The forwarded option namespace changes with the exact LLVM/LLD build and is not a stable, reviewed OriginWeave contract. + +## Constraint and owner + +`tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for repository-selected compiler, rustdoc, linker execution, and external-input authority. Supplemental fixtures consume that classifier; they do not duplicate Cargo production-topology discovery owned by `tests/test_browser_session_trusted_adapter_boundary.py`. + +This repair does not broaden into a blanket linker-option ban. Ordinary modeled linker controls remain allowed when they do not select executable code, external inputs, mutable runtime authority, or an opaque downstream option processor. + +## Decision + +Git-owned Cargo configuration must fail closed when rustc/rustdoc linker forwarding reaches LLD `mllvm` in either GNU-compatible spelling and in split or equals form: + +- `--mllvm ` +- `--mllvm=` +- `-mllvm ` +- `-mllvm=` + +The canonical direct-linker parser classifies those tokens through `_linker_option_forwards_llvm_options()`. Existing `-Wl,`, `--for-linker=`, `-Xlinker`, `-C link-arg=...`, `-C link-args=...`, build/target `rustflags`, build/target `rustdocflags`, and rustdoc doctest compiler forwarding continue to converge on the same authority check. + +## RED → repair evidence + +- Structural RED: `763bcadd7a0b7f5ef8cf7e104214ad5d8ec2b5b1` + - build `rustflags` with `-Wl,--mllvm=...` + - target `rustflags` with split `-Wl,-mllvm,` + - build `rustdocflags` + - rustdoc `--doctest-build-arg` forwarding + - typed linker control `-Wl,-z,relro` remains allowed +- Minimal canonical repair: `af934e55c3684a108dbf8a80e1f4270f64c8cd85` + - adds one LLD-specific classifier to the existing direct-linker authority parser + - reuses all existing Cargo/rustdoc/linker forwarding paths + - does not add a second topology/config scanner + +The repair commit is source-semantic evidence only. It is not a substitute for PR-triggered hosted tests, repository/security checks, current-head review, or the owned 100% documentation/test/edge-case gates. + +## Alternatives rejected + +Allowlisting individual LLVM options was rejected. LLD forwards into LLVM's option processor, whose accepted/debug/experimental surface depends on the exact toolchain build. A local list would become a mutable shadow specification and could silently under-model future LLVM options. + +Path-based approval is not applicable because `mllvm` is an option tunnel rather than a file selector. Treating only currently observed file-consuming LLVM options as dangerous would also confuse present examples with the authority granted by the forwarding mechanism itself. + +## Security and commercial effect + +The build provenance contract now treats direct LLD-to-LLVM option forwarding consistently with rustc `llvm-args`: reviewed Git-owned Cargo configuration cannot introduce an opaque LLVM option channel behind the typed compiler/linker authority model. This reduces the chance that an enterprise release is materially changed by toolchain-internal or experimental LLVM switches that are absent from the reviewed OriginWeave contract and evidence set. + +## Residual authority + +Environment or direct-CLI linker arguments, ancestor or `$CARGO_HOME` configuration, runner image/toolchain identity, exact LLD/LLVM distribution and version, and externally supplied build inputs remain CI/release supply-chain evidence surfaces. Non-LLD linkers and option grammars remain governed only where they are explicitly modeled and evidenced. + +## References + +LLVM Project. (2026). *LLD - The LLVM Linker* (24.0.0git documentation). https://lld.llvm.org/ + +LLVM Project. (2026). *lld/ELF/Options.td* [Source code]. GitHub. https://github.com/llvm/llvm-project/blob/main/lld/ELF/Options.td + +LLVM Project. (2026). *Clang command line argument reference*. https://clang.llvm.org/docs/ClangCommandLineReference.html + +Retrieved September 19, 2026. The primary LLD option table defines `mllvm` as forwarding additional arguments to LLVM option processing; publication/docs freshness does not replace OriginWeave runtime qualification of the exact linker/toolchain used for a release. diff --git a/docs/traceability/browser-session-lld-pass-plugin-authority.md b/docs/traceability/browser-session-lld-pass-plugin-authority.md new file mode 100644 index 000000000..86c022a18 --- /dev/null +++ b/docs/traceability/browser-session-lld-pass-plugin-authority.md @@ -0,0 +1,46 @@ +# Browser Session LLD pass-plugin execution authority + +Status: source-semantic repair; hosted executable evidence pending + +## Problem + +OriginWeave's Browser Session Cargo compiler authority already rejects GNU linker plugin loading through `-plugin` / `--plugin`, but LLVM LLD exposes a separate LTO pass-plugin surface. `--load-pass-plugin=` selects a dynamic pass-plugin library that is carried into the LTO configuration. Before this repair, the joined spelling kept the library path inside a single option token, so the positional-native-input fallback did not see it and the existing GNU-plugin classifier did not match it. + +This is executable-code authority. A repository-owned Cargo `rustflags` or `rustdocflags` value must not be able to load an unreviewed LLVM pass plugin into link-time optimization while the reviewed Cargo package/source closure remains unchanged. + +## Primary evidence + +Evidence is pinned to `llvm/llvm-project@3834f58744a7be80b5869c07fe576ff8f23e2315`. + +- `lld/ELF/Options.td` defines `load_pass_plugins` as `EEq<"load-pass-plugin", "Load passes from plugin library">`, so the supported ELF spelling is the double-dash option with separated or `=`-joined operand. +- `lld/ELF/Driver.cpp` stores `args::getStrings(args, OPT_load_pass_plugins)` in `ctx.arg.passPlugins`. +- `lld/ELF/LTO.cpp` copies each `ctx.arg.passPlugins` filename into `LTO::Config::PassPluginFilenames`, making the selected library part of the LTO pass-plugin execution surface rather than a passive output setting. + +## RED and causal repair + +Structural RED: `b445b22c698a5418398d72947f6a0ca9b65eba38`. + +The existing `tests/test_browser_session_linker_plugin_contract.py` adds a hostile Cargo forwarding case for `-Wl,--load-pass-plugin=tools/review-bypass-pass.so`. Existing `-Wl,--as-needed`, `-Xlinker --as-needed`, and `--for-linker=--as-needed` controls remain allowed. The RED commit changes only that focused contract (`+5/-0`). + +Minimal repair: `3d0efa7b131e761174ddd7bc4b0bcc10b2d59e31`. + +The canonical single writer remains `tests/test_browser_session_cargo_compiler_authority_contract.py`. `LINKER_PLUGIN_OPTIONS` now includes the separated `--load-pass-plugin` spelling, and `_linker_option_loads_plugin()` recognizes the joined `--load-pass-plugin=` spelling. RED-to-repair changes only that authority file (`+2/-2`). No supplemental Cargo/linker scanner, filename allowlist, or LTO-specific source-discovery path was added. + +## Decision + +Repository-owned Cargo/rustdoc linker forwarding must fail closed whenever LLD is instructed to load a pass-plugin library. The boundary treats pass-plugin selection as executable build authority, alongside linker plugin loading, tool replacement, wrapper selection, and other mechanisms that can execute code outside the reviewed build TCB. + +A future buyer requirement for an LTO pass plugin must use a typed, versioned contract that proves the exact plugin artifact and its execution context. A path string or library basename is not sufficient provenance. + +## Rejected alternatives + +- **Rely on positional-input detection.** Rejected because the joined `--load-pass-plugin=` spelling embeds the path inside the option token. +- **Allowlist plugin paths or names.** Rejected because a pathname does not prove immutable bytes, producer identity, toolchain/plugin ABI compatibility, symlink containment, or reproducibility. +- **Treat the option as ordinary linker tuning.** Rejected because LLD explicitly carries the selected filename into `PassPluginFilenames` for LTO execution. +- **Add a second pass-plugin scanner.** Rejected because the existing Cargo compiler authority contract is the canonical single writer for repository-selected compiler/rustdoc/linker execution authority. + +## Acceptance and residual authority + +If pass-plugin execution is ever admitted, release evidence must bind at minimum the plugin digest, producer provenance, LLVM/LLD version and plugin ABI compatibility, target/architecture, containment of the resolved artifact, SBOM/provenance, deterministic or independently reproduced output evidence, invalidation conditions, and rollback procedure. + +This source-semantic repair does not establish hosted GREEN. The exact PR head still requires fresh repository/security execution, current-head independent review, and the configured coverage/rustdoc/docstring gates. Environment and direct-CLI linker arguments, ambient toolchain installation, CI-restored artifacts, and unmodeled non-LLD plugin mechanisms remain CI/release provenance surfaces rather than Browser Session domain truth. diff --git a/docs/traceability/browser-session-lld-plugin-opt-llvm-authority.md b/docs/traceability/browser-session-lld-plugin-opt-llvm-authority.md new file mode 100644 index 000000000..4edc4dc48 --- /dev/null +++ b/docs/traceability/browser-session-lld-plugin-opt-llvm-authority.md @@ -0,0 +1,54 @@ +# Browser Session LLD `plugin-opt=-` LLVM option authority + +Status: source-semantic repair; hosted executable evidence pending + +## Problem + +OriginWeave's Browser Session Cargo compiler authority already fails closed for LLD `-mllvm` / `--mllvm`, but LLVM LLD exposes a second compatibility spelling that reaches the same LLVM option parser. A Git-owned Cargo `rustflags` or `rustdocflags` value can forward `-plugin-opt=-` or `--plugin-opt=-` through the compiler driver. Before this repair, those joined tokens were not classified as LLVM-option authority and could pass the reviewed direct-linker boundary. + +This is an execution/provenance boundary, not a claim that every LLVM option is independently dangerous. The problem is that a reviewed Cargo configuration could open the opaque LLVM option namespace without the same explicit review applied to `-mllvm`. + +## Primary evidence + +Evidence is pinned to `llvm/llvm-project@3ff9abe8930acc7b4c4e2387c1357ca6f2d15c00`. + +- `lld/ELF/Options.td` defines `plugin_opt_eq_minus` as `J<"plugin-opt=-">` and describes it as `Specify an LLVM option for compatibility with LLVMgold.so`. +- LLD's `J` grammar accepts both one-dash and two-dash multi-letter spellings. +- `lld/ELF/Driver.cpp` iterates `OPT_plugin_opt_eq_minus` and calls `parseClangOption(ctx, std::string("-") + arg->getValue(), arg->getSpelling())`. +- The adjacent `-mllvm` path also calls `parseClangOption`, so the two surfaces share the same underlying LLVM option-processing authority even though their command-line spellings differ. + +The generic `plugin-opt=` compatibility path is not blanket-blocked by this decision. LLD explicitly ignores a GCC `lto-wrapper` path and errors on other unsupported generic values. The repair therefore targets only the documented `plugin-opt=-` LLVM-option tunnel rather than treating every `plugin-opt` spelling as equivalent. + +## RED and causal repair + +Structural RED: `a36e22d71b73877c243fa6ecdb34806b00d1f1f5`. + +The existing `tests/test_browser_session_lld_mllvm_authority_contract.py` now covers: + +- build-level `rustflags` with `--plugin-opt=-...`; +- target-level `rustflags` with the one-dash `-plugin-opt=-...` spelling; +- build-level `rustdocflags`; +- rustdoc doctest compiler forwarding; +- an ordinary typed linker control (`-Wl,-z,relro`) as the allowed control. + +Minimal repair: `64627f969f9c04c52ebed4efd3f7ca05b5cb42ea`. + +The canonical single writer remains `tests/test_browser_session_cargo_compiler_authority_contract.py`. `_linker_option_forwards_llvm_options()` now recognizes only the additional `--plugin-opt=-` and `-plugin-opt=-` prefixes. The RED-to-repair compare changes that owner file by one replacement line (`+1/-1`); no second Cargo/linker scanner, pathname allowlist, provider-specific policy, or new topology discovery was introduced. + +## Decision + +Repository-owned Cargo configuration must fail closed when Rust/rustdoc linker forwarding opens LLD's opaque LLVM option-processing namespace through either `mllvm` or `plugin-opt=-`. + +The deterministic Browser Session policy boundary is not delegated to LLVM option behavior. If a future buyer requirement needs a specific LLVM option, it must be modeled as a typed, reviewed contract with its security, reproducibility, toolchain-version, target/architecture, and rollback consequences stated explicitly. Reopening the generic opaque tunnel is not an acceptable shortcut. + +## Rejected alternatives + +- **Block every `plugin-opt=` spelling.** Rejected because current LLD has typed compatibility spellings and a generic GCC `lto-wrapper` compatibility path with different semantics. A blanket ban would conflate unrelated grammar with LLVM-option authority. +- **Allowlist LLVM option strings.** Rejected because an option name alone does not prove semantics across LLVM revisions, targets, code-generation pipelines, or security/reproducibility consequences. +- **Add a supplemental scanner.** Rejected because the existing Cargo compiler authority contract is the single writer for repository-selected compiler/rustdoc/linker execution and input authority. + +## Residual authority and release evidence + +This repair covers Git-owned Cargo `rustflags` / `rustdocflags` paths already consumed by the canonical contract, including doctest compiler forwarding. Environment `RUSTFLAGS`, `CARGO_ENCODED_RUSTFLAGS`, `RUSTDOCFLAGS`, `CARGO_ENCODED_RUSTDOCFLAGS`, direct Cargo/rustc/rustdoc CLI arguments, ambient toolchain configuration, and unmodeled non-LLD linker grammars remain CI/release execution-provenance surfaces. + +The current PR head still requires fresh hosted repository/security execution and whole-current-head review after this source/doc generation. Source-semantic repair and static primary-source traceability do not substitute for protected-head GREEN, reproducibility evidence, SBOM/provenance, or release acceptance. diff --git a/docs/traceability/browser-session-lld-thinlto-cache-authority.md b/docs/traceability/browser-session-lld-thinlto-cache-authority.md new file mode 100644 index 000000000..3d36b2d0e --- /dev/null +++ b/docs/traceability/browser-session-lld-thinlto-cache-authority.md @@ -0,0 +1,57 @@ +# Browser Session LLD ThinLTO cache authority + +## Problem + +Repository-owned Rust/rustdoc linker forwarding could select an LLD ThinLTO cache with `--thinlto-cache-dir=` without entering the Browser Session compiler/linker authority boundary. The joined option keeps the directory path inside one linker token, so the existing positional-native-input fallback did not see it. + +This is an input-provenance problem, not merely a performance/cache setting. LLVM LLD configures its ThinLTO `FileCache` from `thinLTOCacheDir`. On a cache hit, LLVM's local cache opens `llvmcache-` for read, maps the bytes into a `MemoryBuffer`, and passes that buffer to the linker callback. LLD then treats a non-null cached buffer as a native relocatable file and links its bytes. A mutable or externally restored cache can therefore contribute native object bytes to the final Browser Session artifact. + +## Primary evidence + +Evidence was checked against `llvm/llvm-project` source at revision `a312cb0c81cf1e4cf1a3bc469b15bd5216c59469`: + +- `lld/ELF/Options.td` defines `thinlto-cache-dir=` as a joined LLD option whose value is the path to the ThinLTO cached-object directory. +- `lld/ELF/LTO.cpp` passes `ctx.arg.thinLTOCacheDir` to `localCache(...)`, then documents that `files[i]` may contain a native relocatable `MemoryBuffer` supplied by that cache and links the resulting `objBuf`. +- `llvm/lib/Support/Caching.cpp` implements a cache hit by opening `llvmcache-` for read and handing the resulting `MemoryBuffer` to `AddBuffer`. + +The cache key does not turn the cache directory into reviewed source authority: the cache hit path trusts the bytes found at the computed entry path and feeds them into the link. Repository configuration must therefore not be able to select a mutable external cache as an implicit native-object source without an explicit provenance contract. + +## Ownership + +`tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for repository-selected rustc/rustdoc/toolchain/linker execution and input authority. `tests/test_browser_session_trusted_adapter_boundary.py` remains the owner of production Cargo package/source topology. No second Cargo/linker scanner was introduced. + +The boundary is deliberately narrower than general CI cache policy. Organization-level cache storage, restoration, retention, attestation, and runner isolation remain CI/release supply-chain concerns. This slice only prevents Git-owned linker forwarding from selecting a mutable ThinLTO cache directory as an unreviewed native-object input source. + +## RED → repair + +Structural RED `a1829971952c95ab8d8899e24813e74443939873` adds `tests/test_browser_session_lld_thinlto_cache_authority_contract.py`. It requires joined `--thinlto-cache-dir=...` to fail closed through normal Rust linker forwarding and rustdoc doctest compiler forwarding, while keeping typed `-Wl,-z,relro` as an allowed control. + +Minimal repair `38081627f21a1adadf22e1269b1d326c0012a5a4` adds `_linker_option_selects_thinlto_cache()` to the existing canonical direct-linker classifier and consumes it from `_direct_linker_arguments_extend_authority()`. The predecessor-to-repair compare changes only that authority file by `+6/-0`; the RED contract remains separate. + +## Alternatives rejected + +Treating ThinLTO cache selection as performance-only was rejected because upstream `localCache` reads existing cached object bytes and LLD explicitly consumes those buffers as native relocatable inputs. + +Allowing repository-relative cache directories by pathname was rejected. Path location does not establish byte identity, producer identity, restoration provenance, symlink containment, cache poisoning resistance, toolchain compatibility, or reproducibility. + +Reimplementing cache discovery in a separate test/helper was rejected because it would split compiler/linker input authority across multiple writers. + +Disabling ThinLTO itself was not selected. The defect is repository-selected mutable cache authority, not ThinLTO compilation as such. + +## Future exception evidence + +If a buyer-specific workflow later requires ThinLTO caching, the exception must be owned by the CI/release supply-chain boundary and prove at least: + +- exact cache producer/toolchain identity and cache-key inputs; +- immutable or integrity-verified cached object bytes before link consumption; +- repository/job/architecture isolation and purpose-bound access; +- symlink/path containment and restore-source provenance; +- SBOM/provenance attachment tying consumed cached objects to the released artifact; +- reproducibility against a cache-cold rebuild or an equivalent independent build; +- invalidation and rollback behavior when compiler, linker, target, flags, or source inputs change. + +A cache hit, command acknowledgement, or successful link is not evidence that those properties hold. + +## Residual acceptance + +This source repair does not establish hosted GREEN, whole-PR review closure, protected-branch mergeability, or release readiness. Exact-head repository/security checks, current-head review, and the existing parent/central CodeQL prerequisite chain remain required before #317 can advance. diff --git a/docs/traceability/browser-session-llvm-args-authority.md b/docs/traceability/browser-session-llvm-args-authority.md new file mode 100644 index 000000000..204cae1ae --- /dev/null +++ b/docs/traceability/browser-session-llvm-args-authority.md @@ -0,0 +1,47 @@ +# Browser Session direct LLVM argument authority + +Status: Draft evidence for PR #317. This document describes a repository-source provenance boundary; it is not hosted execution or browser-observed acceptance evidence. + +## Problem + +`rustc -C llvm-args="..."` passes arguments directly to LLVM. The rustc book explicitly states that this surface talks directly to LLVM and is not covered by rustc's normal CLI stability guarantees. Current rustc also combines target-spec LLVM arguments with user `-Cllvm-args`, places the user-provided values after target-spec values, and forwards the resulting argument vector to `LLVMRustSetLLVMOptions`; the source notes LLVM `cl::opt` last-wins behavior. + +That means a Git-owned Cargo configuration can bypass the reviewed, typed rustc option surface without changing the Cargo package graph, compiler binary, codegen backend, or linker. Treating individual LLVM flags as ordinary tuning would require OriginWeave to mirror a compiler-version-specific LLVM command-line grammar and continuously distinguish harmless tuning from options that alter execution, consume files, or change target/code-generation behavior. That is not a stable Browser Session contract. + +## Decision + +Repository-owned `llvm-args` is fail closed wherever the existing Cargo compiler-authority contract already accepts Rust flags: + +- `[build].rustflags` and `[target.*].rustflags`; +- profile `rustflags` in the root manifest or Cargo config; +- `[build].rustdocflags` and `[target.*].rustdocflags`; +- rustdoc `--doctest-build-arg` forwarding into rustc. + +The shared `_flags_select_codegen_backend()` classifier recognizes split `-C llvm-args=...`, compact `-Cllvm-args=...`, split `--codegen llvm-args=...`, and `--codegen=llvm-args=...`. Direct rustdoc flag paths now invoke that same classifier rather than maintaining a rustdoc-specific LLVM list. + +Typed, modeled codegen options such as `-C opt-level=2` remain allowed. This is deliberately not a blanket ban on code-generation tuning. + +## Alternatives considered + +An LLVM-argument allowlist was rejected for now. LLVM options are toolchain-version-specific, include hidden/internal options, and are explicitly outside rustc's normal CLI stability contract. A path- or prefix-based heuristic would therefore create a false sense of provenance coverage. + +Blocking only a known dynamic-plugin spelling was also rejected. The security boundary is the direct LLVM option tunnel itself, not one currently observed spelling. If a buyer requires a specific LLVM option later, the correct path is an explicit versioned contract tied to the exact rustc/LLVM toolchain and immutable build evidence. + +## RED -> repair + +- RED: `ef3455a922d9467e8dcd2253dbb820b9dcde7303` adds build, target, profile, direct-rustdoc, and doctest-forwarding hostile cases plus a typed-codegen control. +- Repair: `883f63a4a7706af6d70d93ac6e5fcfb632a7f7b1` extends the existing shared code-generation classifier and wires direct rustdoc flags through that same owner. + +The repair does not create another Cargo topology scanner. `tests/test_browser_session_trusted_adapter_boundary.py` remains the production Cargo package/source-topology owner, while `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the repository-selected compiler/rustdoc/toolchain execution and input-authority owner. + +## Residual boundary + +This source contract does not prove the absence of ambient LLVM arguments supplied outside reviewed Git content. Environment/direct CLI arguments, ancestor or `$CARGO_HOME` configuration, runner images, rustup/toolchain contents, and externally materialized compiler artifacts remain CI/release supply-chain evidence surfaces. A future approved direct LLVM option requires an exact toolchain identity, documented option semantics for that compiler build, reproducibility evidence, SBOM/provenance linkage where applicable, and rollback criteria. + +## References + +Rust Project. (2026). *Codegen options: llvm-args*. The rustc book. https://doc.rust-lang.org/rustc/codegen-options/#llvm-args + +Rust Project. (2026). *rustc_codegen_llvm::llvm_util source*. Nightly rustc documentation. https://doc.rust-lang.org/nightly/nightly-rustc/src/rustc_codegen_llvm/llvm_util.rs.html + +LLVM Project. (2026). *Using the new pass manager*. https://llvm.org/docs/NewPassManager.html diff --git a/docs/traceability/browser-session-native-library-input-authority.md b/docs/traceability/browser-session-native-library-input-authority.md new file mode 100644 index 000000000..c0ccbb250 --- /dev/null +++ b/docs/traceability/browser-session-native-library-input-authority.md @@ -0,0 +1,73 @@ +# Browser Session native-library input authority + +## Problem + +The Browser Session repository contract already constrains Git-owned Cargo settings that replace Rust tools, target runners/linkers, compiler-driver executables, linker plugins, response files, linker scripts, and rustc-managed native tools. That boundary initially did not constrain top-level rustc `-L` and `-l` flags supplied through repository-owned Cargo `rustflags`. + +`-L` changes the search path for external crates and libraries, including native libraries. `-l` asks rustc to link a named native library and supports static archives, dynamic libraries, frameworks, and modifiers such as `+whole-archive`. A reviewed Rust source closure therefore did not prove the final native input closure when a Git-owned `.cargo/config.toml` or `.cargo/config` could add either flag. + +A first repair closed those top-level rustc forms but left an equivalent driver path open: rustc `-C link-arg` and `-C link-args` append arguments to the linker invocation. On Unix-like targets rustc commonly uses `cc` or `clang` as the linker driver, so `link-arg=-L...`, `link-args=-L ...`, `link-arg=-l...`, and `link-args=-l ...` can widen the same native-library search/input closure without using top-level rustc `-L`/`-l` syntax. + +## Constraint + +`tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. The compiler-authority contract may consume that topology and constrain Git-owned compiler/input authority, but it must not reimplement workspace/package discovery. + +This slice applies to repository-owned Cargo configuration. It does not claim authority over environment-injected `RUSTFLAGS` / `RUSTDOCFLAGS`, direct `cargo rustc -- ...` / `cargo rustdoc -- ...` arguments, build-system flags outside the repository, or external toolchain configuration. Those require their canonical CI/supply-chain owner or a separate reviewed contract. + +## RED → repair + +RED `657428dd607c2a384f95865ff59caba704a899d9` adds hostile contract cases for: + +- `-L native=tools/review-bypass-native`, which widens native-library search to a repository-selected path; and +- `-l static:+whole-archive=review_bypass_native`, which asks rustc to link a native static archive as a complete archive. + +The predecessor exact allowed both settings. + +Repair `d3a1790c50c393e0328854a2dd7fe4d9aaf3d5c4` keeps production topology ownership unchanged, normalizes the existing Cargo flag representation once, and extends the compiler-authority contract so Git-owned top-level `rustflags` fail closed on `-L`/`-l` in both `[build]` and `[target.<...>]` settings. Unrelated codegen flags remain allowed. + +Focused review of exact `e934b3c17261ab26bb13b4f02417416c4202d344` then found the forwarded-driver equivalent. RED `ad5090dfb1e6de9eb1e2875365fa2b65ad37a5d9` adds hostile build- and target-scoped cases for compact and split `-C link-arg` / `--codegen=link-args` forms that forward `-L` or `-l` into the linker driver. + +Repair `a0f8525b57837ac119ef7b2af9c1daa759ef12f4` reuses one external-input classifier across top-level rustc flags and the existing linker-driver parser. Direct driver arguments, `-Wl,` / `--for-linker=` forwarded arguments, and the argument following `-Xlinker` now fail closed when they select `-L` or `-l`. Existing response-file, linker-script, plugin, tool-selection, GCC specs/wrapper, and driver-search-path checks remain in the same shared parser. Ordinary non-input linker options such as `-Wl,--as-needed` and `-Wl,-Bsymbolic` remain allowed. + +External-crate injection through Git-owned Cargo `rustflags --extern` is separately closed by `tests/test_browser_session_extern_input_contract.py`. The same external-input classifier now also applies to build- and target-level `rustdocflags` after RED `5928b1a614c8620203675b609b75f5489338e06d` and repair `e8edb487b779a1c4ff22bf2ca4c62ae7e52abd8b`; that rustdoc-specific decision is traced in `browser-session-rustdoc-external-input-authority.md`. + +## Decision + +Until external/native input provenance is modeled as a versioned reviewed contract, repository-owned Cargo configuration must not widen rustc/rustdoc external-library search paths or request additional native/external crate inputs for Browser Session production packages. + +This is an input-provenance rule, not a claim that `-L`, `-l`, or `--extern` are unsafe Rust features. They are rejected here because their resolved artifacts are outside the current exact-head source and artifact review closure. + +## Residual surfaces + +The following remain separate review surfaces and are not pre-authorized by this decision: + +- environment `RUSTFLAGS` / `CARGO_ENCODED_RUSTFLAGS` / `RUSTDOCFLAGS` / `CARGO_ENCODED_RUSTDOCFLAGS` and direct `cargo rustc` / `cargo rustdoc` trailing arguments; +- external-input spellings with equivalent semantics that are not yet modeled by the shared classifier; +- sysroot/rustup/toolchain composition and custom target specifications; +- non-GNU platform-specific native input/control-file mechanisms. + +Positional native inputs, Git-owned Cargo `--extern`, and Git-owned Cargo rustdoc `--extern` / `-L` forms are governed by their supplemental current contracts and are not residual gaps in this repository-owned Cargo boundary. + +A future allowlist must identify the exact artifact path, digest/provenance, producer, target triple, linkage kind, and reproducible build evidence on the same reviewed exact tree. A path-only allowlist is insufficient. + +## Primary evidence + +Rust Project. (2026). *Command-line arguments: `-L` and `-l`*. The rustc book. https://doc.rust-lang.org/nightly/rustc/command-line-arguments.html + +The rustc documentation states that `-L` adds a path searched for external crates and libraries and can be scoped to `dependency`, `crate`, `native`, `framework`, or `all`. It also states that `-l` links the generated crate to a specified native library and supports static archives, dynamic libraries, frameworks, and linking modifiers including `+whole-archive`. + +Rust Project. (2026). *Codegen options: `link-arg` and `link-args`*. The rustc book. https://doc.rust-lang.org/rustc/codegen-options/ + +The rustc documentation states that `link-arg` appends one extra argument and `link-args` appends multiple extra arguments to the linker invocation. It also states that Unix-like targets commonly use a C compiler as the linker driver. + +Free Software Foundation. (2026). *Link options*. Using the GNU Compiler Collection (GCC). https://gcc.gnu.org/onlinedocs/gcc/Link-Options.html + +GCC documents `-l` as searching and linking the named library and states that the search directories include those added through `-L`. Those driver semantics make forwarded `-L`/`-l` part of native input selection rather than inert linker metadata. + +Rust Project. (2026). *Build scripts*. The Cargo book. https://doc.rust-lang.org/cargo/reference/build-scripts.html + +Cargo documents the corresponding native-library and search-path concepts through `cargo::rustc-link-lib` and `cargo::rustc-link-search`, which are passed to rustc as `-l` and `-L` semantics. Build-script authority itself remains separately fail-closed in the Browser Session Cargo build-surface contract. + +## Verification state + +The RED→repair generations are structurally present on the active #317 lineage. This dossier does not promote the branch to executable GREEN: current-head hosted repository/security workflows and independent current-head review must still complete on the reconciled lineage before merge or release readiness can be claimed. diff --git a/docs/traceability/browser-session-navigation-authority.md b/docs/traceability/browser-session-navigation-authority.md new file mode 100644 index 000000000..a40ee73f4 --- /dev/null +++ b/docs/traceability/browser-session-navigation-authority.md @@ -0,0 +1,85 @@ +# Browser Session navigation authority trace + +- Status: `IMPLEMENTED_ON_ACTIVE_PR` +- Owning bounded context: `originweave-browser-session` +- Production owner: #317 +- Acceptance successors: #318, #321 +- Protocol adapter / WebDriver BiDi correlation owner: #316 +- Governing proposals: ADR 0114, ADR 0116 +- Standards provenance: `docs/traceability/webdriver-bidi-publication-current.md` + +## Domain boundary + +Browser Session owns deterministic navigation authority state for an already-owned browsing context. WebDriver BiDi navigation ids, remote context ids, protocol event order, replay qualification, transport liveness, and pending/accepted/quarantined adapter tuples remain #316 concerns. A raw protocol id or an LLM judgment cannot manufacture Browser Session authority. + +The active #317 production lineage implements this contract: + +```text +Active Browser Session ++ exact BrowserSessionIncarnation ++ exact live BrowsingContextId ++ exact current BrowserContextEpoch + | + | record_observed_navigation(...) + | zero adapter I/O; no presentation epoch spent + v +opaque NavigationSettlementAuthority ++ monotonic navigation_generation ++ presentation authority revoked + | + +-- first qualified commit -----------------------> Pending(committed=true) + | | + +-- positive complete observation -----------------+ + +-- typed Aborted / Failed ------------------------+--> Eligible + +-- download start --------------------------------+ | + | explicit, single-use + | reestablish_presentation_authority + | reserves next BrowserContextEpoch + v + Established +``` + +A newer qualified navigation start supersedes an older pending witness or unused eligibility for the same owned context without spending a presentation epoch. Old, foreign, cross-context, cross-incarnation, destroyed-generation, and superseded witnesses fail closed. + +## Invariants and source mapping + +| Invariant | Owner source / evidence | +|---|---| +| Navigation admission validates aggregate trust, incarnation, live ownership, and exact context epoch before mutation | `BrowserSession::begin_observed_navigation`; owner hostile tests | +| Admission performs zero adapter I/O and does not spend a presentation epoch | `BoundBrowserSession::record_observed_navigation`; presentation-epoch conservation tests | +| Browser Session, not adapter ids, mints terminal authority | private fields of `NavigationSettlementAuthority`; `tests/test_browser_session_navigation_owner_surface_contract.py` | +| Navigation generation is monotonic and independent from presentation epoch | `next_navigation_generation`; generation exhaustion tests | +| First commit is non-terminal | `mark_observed_navigation_committed`; #318 commit tests | +| Positive settlement, typed negative termination, and download start share one current-witness terminal closure | `close_observed_navigation`; #318 terminal/download tests | +| Duplicate or superseded commit/terminal evidence is non-authorizing | `current_pending_navigation_mut`; #318 replay tests | +| Terminal closure makes one context-local re-establishment opportunity | `PresentationNavigationState::Eligible`; #318 sibling/context-local tests | +| Re-establishment is explicit, single-use, and the only navigation transition that spends the next presentation epoch | `reestablish_presentation_authority_for_context`; #318 epoch/single-use tests | +| Navigation-invalidated presentation authority cannot authorize mutation or authority-based cleanup | `context_for_authority_mut`; cleanup hostile tests | +| Exact lifecycle owner may still clean up the retained context without reopening presentation authority | `destroy_owned_disposable_context`; cleanup hostile tests | +| RecoveryRequired and TransportLost dominate stale capability inspection | `require_active`; recovery/liveness tests | +| Proven destruction removes only that exact owned generation; sibling progress/eligibility survives | hot ownership map removal; #318/#321 sibling tests | +| Same raw context recreated later cannot inherit prior navigation or presentation authority | monotonic context epoch + session incarnation; #321 ABA matrix | + +## Adapter anti-corruption boundary + +#316 may correlate `browsingContext.navigationStarted`, `navigationCommitted`, completion/abort/failure/download observations, remote context destruction, and transport/session loss to Browser Session commands only after protocol qualification. It must retain protocol identifiers for addressability and provenance, not promote them into policy authority. + +The adapter must bind a remote candidate to the exact aggregate-issued create attempt before accepted state becomes authorizing. Navigation evidence for a rejected, quarantined, superseded, destroyed, or prior-incarnation tuple cannot rewrite Browser Session state. Silent rebind after transport/session loss is prohibited. + +Browser Session does not own Chromium/WebDriver transport truth. #316 does not own Browser Session policy truth. No source copy, cross-service SQL, mutable dependency, or duplicate state machine is permitted across this ACL. + +## Acceptance state + +#317 production semantics are implemented on an active PR, not protected-main shipment. #318 owns the broad navigation acceptance matrix and doctoring; #321 owns same-raw-id/sibling-recreation ABA acceptance. Those child PRs must remain ordinary non-force descendants of the exact #317 owner and do not replace owner-side contracts. + +Repository GREEN requires current exact-head repository contracts, canonical formatting, locked Rust tests, strict Clippy, rustdoc/API docs, production function/line/region/branch coverage at 100%, and applicable protected checks. Queued, skipped, predecessor, or runner-less jobs are not GREEN. + +Real-browser GREEN is separate. A protocol command acknowledgement is insufficient. Acceptance requires pinned Chromium/WebDriver BiDi evidence for navigation, policy-authorized interaction, browser/page-observed post-condition, reset, destruction/cleanup, crash/recovery behavior, and provenance on the current integrated head. + +## Standards trace + +WebDriver BiDi publication freshness is consumed from the canonical `originweave-bidi` receipt at `docs/traceability/webdriver-bidi-publication-current.md`; this Browser Session navigation trace does not restate dated Working Draft currentness. Protocol event vocabulary and user-context/browsing-context addressability come from the versioned adapter contract; OriginWeave's opaque navigation authority and lifecycle invariants are internal domain controls and are not claimed as W3C requirements. Runtime compatibility remains independently qualified from publication metadata. + +## Release status + +Status remains `IMPLEMENTED_ON_ACTIVE_PR`. Do not promote it to Accepted, released, or buyer-complete until protected-main integration and immutable release evidence exist. #316 integration and real pinned-Chromium post-condition evidence remain open. diff --git a/docs/traceability/browser-session-path-meta-lexical-authority.md b/docs/traceability/browser-session-path-meta-lexical-authority.md new file mode 100644 index 000000000..03c9d4533 --- /dev/null +++ b/docs/traceability/browser-session-path-meta-lexical-authority.md @@ -0,0 +1,69 @@ +# Browser Session Rust path-meta lexical authority + +## Scope + +OriginWeave treats Rust module-source selection as Browser Session build provenance because a `#[path = ...]` meta item can redirect a reviewed module declaration to different source bytes. Cargo production package/source discovery remains owned by `tests/test_browser_session_trusted_adapter_boundary.py`; this slice only classifies already-discovered Rust attribute bodies and does not rediscover Cargo topology. + +## Problem + +The predecessor `_has_path_meta()` searched every extracted attribute body with a raw regular expression for `path` followed by Rust trivia and `=`. `_rust_attribute_bodies()` correctly excluded comments and string literals while locating attribute boundaries, but the inner classifier then rescanned the attribute body without the same lexical discipline. Consequently harmless data such as `#[doc = "path = \"review_bypass.rs\""]`, a raw doc string containing the same text, or `/* path = ... */` inside an attribute was classified as executable module-source authority. + +That is a false-positive authorization result: the gate can reject reviewed source even though Rust has no `path = ...` meta item at that lexical position. Keeping such over-approximation indefinitely would create pressure to weaken the provenance gate instead of making the classifier correspond to Rust syntax. + +The first lexical repair exposed the complementary false-negative: Rust raw identifiers use the `r#IDENTIFIER` spelling while the `r#` prefix is not part of the identifier itself. A raw `r#path = "..."` meta item therefore names the same `path` attribute authority, but the initial lexical classifier called the shared identifier helper with raw identifiers disabled and could miss that source-selection surface. + +## Constraints + +- Do not weaken actual `#[path = ...]`, raw-identifier `#[r#path = ...]`, or nested `cfg_attr(..., path = ...)` fail-closed behavior. +- Do not create a second Cargo package/source scanner. +- Reuse the existing Rust trivia, raw-string, quoted-string, character-literal, and identifier-boundary helpers so source-indirection policies share one lexical model. +- Treat comments as lexical trivia and literals as data, consistent with the Rust Reference. +- Treat a raw identifier according to Rust identifier identity rather than as inert spelling data. +- This is a source-semantic contract repair. It is not hosted exact-head GREEN, protected-main integration, or release evidence. + +## Alternatives considered + +1. Keep the raw regex and add allowlist entries for documentation strings. Rejected because allowlists would encode incidental text and would still miss arbitrary comment/literal spellings. +2. Parse Rust with a second external parser in this Python contract. Rejected because this policy only needs one bounded lexical distinction and a second parser would create another source-of-truth and dependency surface. +3. Reuse the existing lexical helpers inside `_has_path_meta()`. Selected because it is the smallest causal repair and preserves the existing source-indirection owner. +4. Accept only the ordinary spelling `path` and reject or ignore `r#path`. Rejected because Rust raw-identifier syntax denotes the underlying identifier without `r#`; provenance classification must not depend on that surface spelling. + +## Decision and exact evidence + +Structural RED **`67789f0cdb55825e1b6caa91b38643dad890759e`** adds a supplemental contract proving three data-token controls and one real nested path-meta authority case: + +- ordinary doc-string text containing `path = ...` must not be classified; +- raw doc-string text containing `path = ...` must not be classified; +- non-doc comment text containing `path = ...` must not be classified; +- `cfg_attr(unix, path /* trivia */ = "unix_adapter.rs")` must remain classified. + +The predecessor returns `True` for all four cases, so the first three controls are structural RED rather than documentation-only assertions. + +Minimal repair **`79ad52cdd2a29da5cedbf1a134aec8779227bdb4`** changes only `_has_path_meta()` in the canonical Rust source-indirection contract. It walks the attribute body using `_skip_rust_trivia()`, `_raw_string_end()`, `_quoted_string_end()`, `_simple_char_literal_end()`, and `_rust_identifier_token_end()`. A lexical `path` token followed by Rust trivia and `=` still returns `True`; comments and string/character data are skipped before token classification. + +Follow-up RED **`f8f6e665c2651198825fe806781838ffb1165493`** adds `r#path = "raw_identifier.rs"` as a source-selection authority case. The first lexical repair returns `False` for that spelling, demonstrating a false-negative bypass rather than an invented edge case. + +Follow-up repair **`debd5f62b0de4edf45d786859ba6cbfb96f3dd29`** keeps the same lexical classifier and changes only the shared identifier-token call for `path` to `allow_raw=True`. This preserves ordinary `path`, comment/literal exclusion, and nested `cfg_attr` behavior while classifying the raw spelling as the same attribute authority. + +No Browser Session runtime code, Cargo topology owner, WebDriver BiDi policy, Wardnet/EgressWeave/Keyverse/contextual-orchestrator contract, workflow, ruleset, or release surface is changed by these repairs. + +## Standards and implementation traceability + +Rust attributes are tokenized as `# [ Attr ]` / `#! [ Attr ]`; the meta-item grammar includes `SimplePath = Expression` and nested meta-item sequences. The same Reference describes ordinary non-doc comments as whitespace. Rust's identifier grammar includes raw identifiers, and the `r#` prefix is not part of the actual identifier. These rules justify recognizing lexical meta-item tokens while excluding comment/literal payload text and treating `path` / `r#path` as the same identifier authority. + +The current rustc source independently confirms the compiler-side owner: `rustc_attr_parsing::attributes::path::PathParser` registers the attribute under `sym::path`, and module expansion selects the first attribute satisfying `has_name(sym::path)` before reading its string value. OriginWeave does not copy that parser; this implementation evidence only anchors the security contract to the compiler behavior it is constraining. + +- Rust Project. (2026). *The Rust Reference: Attributes*. https://doc.rust-lang.org/reference/attributes.html +- Rust Project. (2026). *The Rust Reference: Comments*. https://doc.rust-lang.org/reference/comments.html +- Rust Project. (2026). *The Rust Reference: Identifiers*. https://doc.rust-lang.org/reference/identifiers.html +- Rust Project. (2026). *The Rust Reference: Paths*. https://doc.rust-lang.org/reference/paths.html +- Rust Project. (2026). `compiler/rustc_attr_parsing/src/attributes/path.rs`, revision `971903d9aee24befd88423f42826c232e27c8190`. https://github.com/rust-lang/rust/blob/971903d9aee24befd88423f42826c232e27c8190/compiler/rustc_attr_parsing/src/attributes/path.rs +- Rust Project. (2026). `compiler/rustc_expand/src/module.rs`, revision `971903d9aee24befd88423f42826c232e27c8190`. https://github.com/rust-lang/rust/blob/971903d9aee24befd88423f42826c232e27c8190/compiler/rustc_expand/src/module.rs + +## Security and operability effect + +The repair does not broaden which module-source selectors are permitted. It removes false-positive policy findings caused by inert attribute data and removes the raw-identifier false-negative that could make a real source selector invisible to the provenance contract. A rejected path attribute therefore corresponds to lexical Rust metadata and remains fail closed across ordinary and raw identifier spellings. + +## Residual risk and follow-up + +The contract is deliberately lexical rather than a complete Rust parser. New Rust attribute/macro forms that can select source bytes without a lexical `path = ...`-equivalent meta item remain a future provenance finding and must be introduced with a hostile fixture and primary-language evidence. Exact-head hosted tests and independent current-head review remain required before this generation can be treated as executable GREEN or release-ready. diff --git a/docs/traceability/browser-session-pgo-profile-input-authority.md b/docs/traceability/browser-session-pgo-profile-input-authority.md new file mode 100644 index 000000000..ce854bb6b --- /dev/null +++ b/docs/traceability/browser-session-pgo-profile-input-authority.md @@ -0,0 +1,51 @@ +# Browser Session PGO profile input authority + +## Decision + +OriginWeave treats repository-selected profile-guided optimization data as compiler input provenance, not as a harmless optimization preference. + +Git-owned Cargo `rustflags`, profile `rustflags`, and `rustdocflags` must therefore fail closed when they select `-C profile-use=` or `-C profile-sample-use=`. The same rule applies when rustdoc forwards those arguments to the doctest compiler through `--doctest-build-arg`. + +`-C profile-generate=` remains allowed by this contract because it names an output location for newly collected profile data rather than an input consumed to shape the current binary. Approval of a future PGO workflow requires a separate immutable profile-artifact contract rather than weakening this rule. + +## Why this is provenance-sensitive + +Rust's PGO documentation defines the optimization workflow as collecting runtime profile data and feeding the resulting profile back into a later compilation. `-C profile-use=` supplies instrumentation-derived `.profdata`; `-C profile-sample-use=` supplies sampling-profile data. LLVM uses those data to guide inlining, machine-code layout, register allocation, and related optimization decisions. Two builds from the same reviewed Rust source and dependency graph can therefore produce materially different machine code when the profile input differs. + +The current rustc option model represents `profile_use` and `profile_sample_use` as path-bearing compiler inputs. A mutable or runner-local profile path would sit outside the reviewed Cargo package/source closure and outside the native/linker provenance rules already enforced by Browser Session. + +## RED → repair + +RED `8842bb09d17d1ed3861080b3cf8ba7779a354123` added hostile fixtures proving that the prior canonical Cargo compiler-authority contract accepted: + +- build-level `-Cprofile-use=...`; +- target-level split `-C` + `profile-sample-use=...`; +- Cargo profile `--codegen=profile-use=...`. + +Repair `76e8e8944475e068f84023758cc8fd6b83275a45` keeps the existing Cargo compiler-authority test as the single policy owner. It adds one `_codegen_option_extends_external_inputs()` classifier and extends `_flags_extend_external_link_inputs()` to parse split, compact, and long `-C`/`--codegen` forms. The rustdoc doctest forwarding path now reuses that same external-input classifier rather than maintaining a parallel list. + +Coverage commit `6fa0537538789b4c899466ab1619e7d24bad8b36` adds direct rustdoc and `--doctest-build-arg` hostile fixtures and retains `profile-generate` as a control. + +## Boundary and future approval requirements + +This contract governs only Git-owned repository configuration already traversed by the canonical Browser Session Cargo authority test. It does not claim control over ambient `RUSTFLAGS`/`CARGO_ENCODED_RUSTFLAGS`, direct CLI injection, ancestor or `$CARGO_HOME` configuration, runner images, or externally materialized profile artifacts. Those remain CI/release supply-chain evidence surfaces. + +If OriginWeave later adopts PGO deliberately, the profile must be a versioned immutable artifact with at least: + +- exact source/workload/toolchain identity and generation procedure; +- content digest and immutable storage identity; +- workload/data provenance and purpose constraints; +- compiler/LLVM compatibility evidence; +- SBOM/provenance linkage to the binary that consumes it; +- reproducibility comparison against the non-PGO reference build; +- rollback and expiry/re-generation policy. + +A path allowlist alone is insufficient because the profile contents, not only the pathname, influence generated machine code. + +## References + +The Rust Project Developers. (2026). *Codegen options: profile-use*. The rustc book. https://doc.rust-lang.org/rustc/codegen-options/#profile-use + +The Rust Project Developers. (2026). *Profile-guided optimization*. The rustc book. https://doc.rust-lang.org/nightly/rustc/profile-guided-optimization.html + +The Rust Project Developers. (2026). *CodegenOptions in rustc_session::options*. Rust compiler documentation. https://doc.rust-lang.org/nightly/nightly-rustc/rustc_session/options/struct.CodegenOptions.html diff --git a/docs/traceability/browser-session-production-source-containment.md b/docs/traceability/browser-session-production-source-containment.md new file mode 100644 index 000000000..a583b7c9a --- /dev/null +++ b/docs/traceability/browser-session-production-source-containment.md @@ -0,0 +1,63 @@ +# Browser Session production-source containment + +Status: Draft evidence on PR #317; this file does not claim protected-main shipment or executable exact-head GREEN. + +## Problem + +OriginWeave's Browser Session trusted-adapter contract derives one Cargo production package/source closure and uses it to review `DisposableContextPort` references, Browser Session dependencies, and caller-selected lifecycle binding. The closure already rejects workspace members, local path dependencies, and explicit Cargo target paths that resolve outside the repository review root. + +The remaining gap was default Rust source discovery. The canonical scanner used `manifest.parent.glob("src/**/*.rs")`, which returns a lexically in-repository path even when the Rust file itself is a symlink whose resolved target is outside the repository. That external file can therefore participate in Cargo's default production target while its bytes are not fixed by the OriginWeave exact Git head. This is a provenance and TCB-review defect even when the scanner happens to read the external bytes on one runner. + +Cargo's current target reference documents `src/lib.rs`, `src/main.rs`, and `src/bin/` as default production source locations and permits manifest-relative explicit target paths. OriginWeave therefore treats the resolved filesystem object behind every source returned by the canonical Cargo topology scanner as part of the exact-head review boundary, not only the lexical path. + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py` is the single writer for Cargo package/source discovery **and** repository-containment validation. +- Supplemental hostile-fixture tests must delegate to that canonical function rather than wrap it with a second provenance implementation. +- Repository-external production source is rejected; it is not made trusted by a symlink placed inside the repository. +- Registry and released external dependencies remain dependency provenance concerns and are not reclassified as repository-local source. +- No future BiDi lifecycle adapter path is pre-authorized. + +## RED + +Commit `f157c215a49d203be2fbe146460ddf22c9081002` rewired `tests/test_browser_session_production_source_containment_contract.py` so its hostile default-source symlink fixture calls canonical `_workspace_production_sources(root)` directly and requires that function itself to fail closed. + +At predecessor exact `3fd55acfaa58fde61f1ca9236db91df2b18320ad`, canonical `_workspace_production_sources(root)` only collected lexical `src/**/*.rs` paths plus explicit target paths. Repository containment lived in supplemental `_reviewed_production_sources(root)`, so the direct canonical call returned the external-target symlink instead of raising. The new test therefore exposes a real single-writer violation. No PR-triggered workflow run is emitted for the Draft head, so this remains structural RED evidence rather than runner-backed RED. + +## Minimal repair + +Commit `7cc1cfaec705c6980a59af84bf4459b3e358873a` moves the provenance postcondition into canonical `_workspace_production_sources(root)`: + +1. gather the existing Cargo production source closure without changing workspace, dependency, or target discovery; +2. resolve every discovered source; +3. require the resolved source to remain beneath `root.resolve()`; +4. require the resolved object to be a file; +5. return the reviewed lexical paths only after those checks succeed. + +The supplemental production-source containment test now contains only current-tree and hostile fixtures. It no longer defines a second `_reviewed_production_sources` policy function. Lifecycle-SPI reference review, Browser Session dependency review, lifecycle binding review, and the hostile provenance fixture therefore consume one canonical source closure and one containment decision. + +This repair changes no Rust Browser Session semantics. It tightens the evidence boundary that determines which source bytes are eligible to participate in the privileged browser-integration TCB. + +## Alternatives considered + +### Keep containment in a supplemental wrapper + +Rejected after the current review. It produced two policy writers: canonical trusted-adapter tests consumed `_workspace_production_sources` directly while only the supplemental containment test consumed `_reviewed_production_sources`. Future callers could therefore bypass the provenance guard without noticing. + +### Ignore symlinks because CI reads the external target + +Rejected. Reading bytes from an external filesystem object during one run is not immutable exact-head provenance and makes review/reproduction depend on runner state. + +### Ban all source symlinks + +Rejected as broader than necessary. A symlink whose resolved target remains inside the repository can still be covered by the exact-head review boundary; the security invariant is containment of the resolved source object. + +## Evidence and follow-up + +Current repair exact: `7cc1cfaec705c6980a59af84bf4459b3e358873a`. + +The branch remains Draft and diverged from canonical parent #229. This file is therefore structural/source-contract evidence only. Required follow-up is exact-head independent review, then the existing parent-first lineage sequence: terminal #229 evidence, ordinary/non-force #229→#317 ancestry reconciliation with zero valid-delta loss, fresh #317 executable evidence, then #318 → #321 → #316. Real pinned-Chromium acceptance remains downstream under #299 and the canonical `.github` MV3 workflow/sandbox owner. + +## Reference + +The Cargo Project Developers. (2026). *Cargo targets*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/cargo-targets.html diff --git a/docs/traceability/browser-session-production-topology-single-writer.md b/docs/traceability/browser-session-production-topology-single-writer.md new file mode 100644 index 000000000..6c2ff2014 --- /dev/null +++ b/docs/traceability/browser-session-production-topology-single-writer.md @@ -0,0 +1,52 @@ +# Browser Session production-topology single writer + +- **Status:** active-PR security evidence for PR #317; not protected-main behavior +- **Owner:** OriginWeave Browser Session bounded context +- **Related contract:** `tests/test_browser_session_trusted_adapter_boundary.py` + +## Problem + +The Browser Session trusted-adapter gate had two different definitions of the production Cargo topology. The canonical boundary test scanned only explicit workspace members for `bind_lifecycle_port`, while the later implicit-workspace contract separately followed recursive in-repository `path` dependencies and custom `[lib].path` / `[[bin]].path` targets. + +That split left a concrete composition escape: an implicit local path dependency could contain `bind_lifecycle_port` without a `DisposableContextPort` token or direct Browser Session dependency in that source file. The supplemental topology test would discover the file, but the canonical caller-selected binding gate would not inspect it. A lifecycle binding in such a package could therefore widen product composition without entering the same fail-closed check that protects explicit workspace members. + +A second review found that the recursive path resolver silently returned `None` when a production `path` dependency resolved outside the repository review root. Cargo permits local path dependencies outside the repository, but this security contract cannot inspect such a package's source, manifest evolution, or lifecycle binding. Silently omitting it would treat an unreviewable production dependency as if no production edge existed. + +A third review found that the canonical closure followed ordinary and workspace-inherited `path` dependencies but did not model Cargo dependency-source overrides. Root-manifest `[patch]` / `[replace]` and Git-owned Cargo configuration can cause Cargo's resolved production graph to differ from the direct dependency declarations inspected by the trusted-adapter scanner. Cargo configuration supports local `paths`, `[patch]`, and `[source]` replacement surfaces and is hierarchical according to the directory from which Cargo is invoked. Restricting review to only the repository-root `.cargo/config*` therefore leaves a Git-owned nested config usable whenever a repository command executes below that directory. + +## RED and repair + +- **Structural RED `a0fb0765d7df8303df490c97dbb5945b1682d837`** adds a hostile `app -> ../plugins/browser-adapter` fixture whose implicit local package calls `bind_lifecycle_port`. The enhanced supplemental production-source closure finds the source, while the canonical `_workspace_production_sources` scanner does not. +- **Minimal causal repair `6b050ee820a29342a9a180638a38b85b33cd66a3`** moves recursive in-repository production `path` dependency traversal and custom production target discovery into the canonical trusted-adapter boundary. The same source closure now drives lifecycle-SPI references, Browser Session dependency allowlists, dormant-entry equality, and caller-selected lifecycle-binding rejection. +- **Single-writer cleanup `18b560c869c5e967a3226d5407bc7216b80f9c80`** removes the duplicate Cargo-topology implementation from the implicit-workspace contract and delegates its hostile fixtures to the canonical boundary scanner. +- **External-path RED `0b0204b30585a2a5921a7b1e193121daf23aff50`** adds a production dependency whose manifest lives outside the repository review root. +- **Fail-closed repair `95c49d22e557466c063124989808d02ae363c609`** makes a declared production Cargo `path` dependency outside the repository review root an explicit contract failure. Missing declared local manifests fail closed as well. +- **Review-driven coverage `600547a4f7cef3a9a22744e13e2890538f21b68a`** adds the direct hostile regression for an in-repository production `path` dependency whose declared `Cargo.toml` is absent. +- **Manifest source-override RED `1c72ea693e47be05b94b330a334118446cc99f39`** adds hostile root-workspace `[patch.crates-io]` and `[replace]` fixtures. +- **Manifest source-override repair `9c3e4780fea9d111f4a362e63ef9531a3b023635`** fails closed when the workspace-root manifest contains a non-empty `[patch]` or `[replace]` table. +- **Repository-config RED `738cbea2ebfb85a966c709a88af8f10f974d4da6`** adds hostile repository-root `.cargo/config.toml` `paths`, legacy `.cargo/config` `[patch.crates-io]`, and `[source] replace-with / directory` fixtures. +- **Repository-config repair `86d24cf6afefa3bd594dad5d7062ec455d8c0572`** extends the same correction-owning package scanner to reject those source-altering repository configs. +- **Independent-review RED `4bdad2aabac71e4fb036aa0b83f0191eac981010`** adds a Git-owned nested `adapter/.cargo/config.toml` local-path override. The earlier root-only scanner misses it even though Cargo configuration is selected hierarchically from the command working directory. +- **Nested-config repair `1c2dfab389a72e9d59c1ca4cdf11d233d490d8a5`** keeps a single scanner but discovers both `.cargo/config.toml` and legacy `.cargo/config` anywhere below the repository review root, requires each resolved config file to remain inside that root, and rejects source-altering `paths`, `[patch]`, or `[source]` tables. Ordinary Cargo settings that do not alter package sources remain outside this fail-closed rule. + +## Invariant + +There is one repository-security definition of the Browser Session production package/source closure. It starts from explicit workspace packages and an optional workspace-root package, follows production in-repository `path` dependencies including workspace-inherited target-specific dependencies, and includes ordinary `src/**/*.rs` plus manifest-declared `[lib].path` and `[[bin]].path` sources. + +The closure fails closed for unsupported workspace-member globs, declared production local-path dependencies that leave the repository review root, missing declared local dependency manifests, missing declared production targets, declared target paths outside the repository review root, Cargo build-script/build-dependency surfaces without generated-source provenance, root-manifest `[patch]` / `[replace]`, and Git-owned `.cargo/config.toml` / `.cargo/config` source-override surfaces anywhere inside the repository. It must never convert an unreviewable production dependency or source override into absence. + +Every source in that closure is subject to the same lifecycle-SPI and caller-selected binding checks. A supplemental hostile fixture may exercise the scanner, but it must not maintain a second production-topology algorithm. + +Cargo can also merge configuration from directories outside the Git review root, `$CARGO_HOME`, environment-derived settings, and command-line `--config`. Those are execution-environment inputs rather than repository source. They must be captured or excluded by the canonical CI/release environment before an executable build is treated as reproducible exact-head evidence; this repository contract does not pretend that untracked ambient configuration is Git-owned source. + +## Primary references + +Cargo Team. (2026). *Workspaces*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/workspaces.html + +Cargo Team. (2026). *Dependency resolution*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/resolver.html + +Cargo Team. (2026). *Configuration*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/config.html + +## Scope + +This repair changes repository security coverage only. It does not change Browser Session runtime semantics, WebDriver BiDi protocol authority, Chromium behavior, or the trust classification of privileged in-process adapters. Exact-head executable repository/security evidence remains required after the parent lineage is reconciled and the PR becomes runnable. diff --git a/docs/traceability/browser-session-profile-rustflags-authority.md b/docs/traceability/browser-session-profile-rustflags-authority.md new file mode 100644 index 000000000..03a96f3db --- /dev/null +++ b/docs/traceability/browser-session-profile-rustflags-authority.md @@ -0,0 +1,42 @@ +# Browser Session Cargo profile rustflags authority + +## Problem + +Cargo's unstable profile `rustflags` option can be selected from the root workspace manifest with `cargo-features = ["profile-rustflags"]` or from Cargo configuration with the `profile-rustflags` unstable feature enabled. Cargo documents these profile flags as arguments passed directly to `rustc`. + +The Browser Session Cargo authority contract already classified build-level and target-level `rustflags`, but did not inspect `rustflags` nested under `[profile.*]`. A Git-owned profile could therefore reintroduce execution or compiler-input authority such as `-C linker=...`, `--extern`, `--sysroot`, a top-level rustc response file, or `-Zcodegen-backend=...` without changing the reviewed production package/source topology. + +## Constraint + +`tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. This repair must consume that topology and the existing compiler/linker/input classifiers rather than add another package, dependency, or source scanner. + +The `profile-rustflags` capability itself is not prohibited. Profile flags that do not extend execution or external-input authority remain allowed. + +## RED and repair + +- RED `e7aafa552c923f121ee88e92fcca9b7ad43ca363` adds `tests/test_browser_session_profile_rustflags_authority_contract.py`. It covers root-manifest profile `--extern`, Cargo-config profile linker selection, and an ordinary `opt-level`/`cfg` control. +- Repair `38c1c06fe6942a1024ea73148b0c8b6ccc9f4405` extends only `tests/test_browser_session_cargo_compiler_authority_contract.py`. `_configured_profile_rustflag_authority()` recursively inspects profile tables and reuses `_flags_select_codegen_backend()`, `_flags_select_linker()`, and `_flags_extend_external_link_inputs()`. +- Root `Cargo.toml` profiles and repository `.cargo/config.toml` / `.cargo/config` profiles now fail closed only when profile `rustflags` widen execution or compiler-input authority. Existing direct profile `codegen-backend` handling remains separate. + +## Primary evidence + +Cargo source and documentation were checked against `rust-lang/cargo@8814ead110e36ed8fdcf1fdd4009baf82bd78523`. + +- `doc/book/src/reference/unstable.md`, “Profile `rustflags` option”, describes profile `rustflags` as passed directly to `rustc`, including `[unstable] profile-rustflags = true` with `[profile.release] rustflags = [...]` in Cargo configuration. +- Cargo profile configuration is owned by the root workspace manifest and may also be supplied through Cargo configuration. The contract therefore inspects both Git-owned surfaces while keeping production topology ownership unchanged. + +## Decision and security effect + +Repository-selected profile `rustflags` are treated as another spelling of the same rustc execution/input authority already governed for build and target flags. The contract rejects profile flags that: + +- select a code-generation backend; +- select or reconfigure linker execution, linker plugins, scripts, response files, or positional linker inputs through the existing linker classifier; or +- add opaque/external compiler inputs such as leading `@path`, `--sysroot`, `--extern`, `-L`, or `-l`. + +Ordinary profile flags such as optimization level or reviewed `--cfg` values remain permitted. This avoids turning an execution-provenance contract into a blanket Cargo-profile policy. + +## Residual authority + +This repository-source contract does not claim control over environment or direct-CLI profile injection, including `CARGO_PROFILE__RUSTFLAGS`, ancestor or `$CARGO_HOME` configuration, command-line `--config`, or runner/toolchain mutation outside the reviewed tree. Those are CI/release environment provenance surfaces and require exact runner/configuration evidence rather than source-copying environment policy into OriginWeave. + +The repair is source-semantic until the exact PR head receives hosted repository/security execution evidence. Static source inspection is not a substitute for executable GREEN. diff --git a/docs/traceability/browser-session-rust-compile-time-environment-authority.md b/docs/traceability/browser-session-rust-compile-time-environment-authority.md new file mode 100644 index 000000000..30a408e3a --- /dev/null +++ b/docs/traceability/browser-session-rust-compile-time-environment-authority.md @@ -0,0 +1,85 @@ +# Browser Session Rust compile-time environment authority + +## Status + +Implemented on PR #317 as a focused repository contract. This document records source-semantic evidence only; it is not hosted CI, protected-main integration, or release evidence. + +## Problem + +Rust 1.98.1 documents `env!` as reading an environment variable at compile time and expanding to its string value, and `option_env!` as the optional form that expands to `Option<&'static str>`. These values can therefore enter Browser Session artifacts without appearing in the reviewed Rust source, dependency graph, or linker-input set. + +Cargo can also set compilation environment values through build-script `cargo::rustc-env=VAR=VALUE`. The Cargo Book explicitly describes retrieving such values with `env!` in the compiled crate. Repository `[env]` configuration is already governed by `tests/test_browser_session_cargo_environment_authority_contract.py`, but that contract does not make every ambient runner variable or build-script-produced value part of reviewed artifact provenance. + +Before this generation, the Rust source-indirection owner governed `include!`, module/path indirection, and the shared Rust lexical helpers; the embedded-file supplement governed `include_bytes!` and `include_str!`. Neither classified direct source-level `env!` or `option_env!`. The initial compile-time-environment repair then closed direct and namespaced spellings but still allowed the same built-in macros to be imported under a callable alias such as `use std::env as read_build_env; read_build_env!(...)`. Rust resolves that alias as the macro, so the artifact can still depend on ambient build state while the invocation no longer contains the literal token `env` or `option_env`. + +The callable-alias generation exposed a second, opposite risk in the lexical classifier: its direct macro token used Python `\w` boundaries. Rust identifiers instead follow Unicode `XID_Continue`. A combining mark such as U+0301 can therefore be part of a larger Rust identifier while Python `\w` reports it as non-word. A valid user macro such as `_\u0301env!()` could consequently be misclassified as the built-in `env!` solely because the substring `env` follows a continuation scalar. That is a false positive in a fail-closed security contract and would make legitimate source unreviewable for the wrong reason. + +The subsequent shared source-indirection cleanup removed the remaining Python-regex `USE_TOKEN` and `AS_TOKEN` owners and moved strict `use`/`as` recognition to `_rust_identifier_token_end()`. The compile-time-environment supplement still dereferenced those removed constants. That made its callable-alias path stale against its canonical lexical owner and would raise before it could classify a real alias. It also meant this supplement had not actually inherited the Unicode-correct `use`/`as` boundary it claimed to consume. + +## Decision + +Keep `tests/test_browser_session_trusted_adapter_boundary.py` as the single writer for production Cargo package/source topology and `tests/test_browser_session_rust_source_indirection_contract.py` as the shared Rust lexical/source-indirection owner. + +`tests/test_browser_session_rust_compile_time_environment_authority_contract.py` remains a focused supplemental contract that: + +- first consumes the existing source-indirection assertion; +- consumes the canonical production-source closure instead of rediscovering Cargo topology; +- reuses the shared trivia, raw-string, quoted-string, character-literal, `use`/`as`, use-statement, and Rust identifier-boundary helpers; +- fails closed on lexical `env!` and `option_env!`, including namespaced spellings; +- resolves `env` / `option_env` token boundaries through the shared Rust identifier-token helper rather than Python regex `\w` semantics; +- resolves strict `use` and `as` through that same shared identifier-token helper rather than owning regex tokens or dereferencing removed compatibility constants; +- fails closed when a Rust `use` tree gives either macro a callable direct, grouped, or raw-identifier alias; +- treats exact `as _` as a discard import, while `_` followed by a Rust identifier-continuation scalar remains a callable identifier rather than a discard alias; +- ignores mentions inside comments and string/character/raw-string literals; +- conservatively rejects locally shadowed macros with the exact built-in names until macro-expansion provenance is modeled. + +The policy does not treat runtime `std::env::var` as the same build-input class. Runtime environment access is a separate product/runtime authority concern and must be governed by the runtime boundary that owns it. + +## RED → repair evidence + +Structural RED: `87eb778a1b5526811e43b851fe839755ee224ca2`. + +The RED adds realistic workspaces whose production Rust source calls `env!` and `option_env!` and asks the pre-existing source-indirection assertion to reject them. The predecessor `c52ad3a5fdcc7e482e8a6b872e1e3d44fed6477a` has no compile-time environment classifier, so those assertions expose the missing provenance boundary while comment/string controls remain accepted. + +Minimal repair: `a6eec1a700aff4cd5ad807629e6f55e44abde2aa`. + +The repair stays inside the focused contract. It adds one compile-time-environment macro classifier, delegates source discovery and lexical handling to existing owners, adds a current-production postcondition, covers direct/optional/namespaced forms, and preserves comment/string controls. No Cargo topology, runtime environment policy, browser behavior, linker authority, or cross-repository owner is duplicated. + +Focused review of traceability exact `75eb414f69a7be2dcc851aca58d47e156a41133c` found a valid fixture-coverage gap rather than a classifier defect: the supplemental contract depended on shared raw-string and character-literal handling without directly exercising those boundaries, and it covered namespaced `env!` but not namespaced `option_env!`. Review-driven coverage repair `f9934fe67c6cf7bd5c0ab946be6b881503a14c65` changes only the focused contract (`+19/-1`). It adds a raw-string false-positive control, proves that scanning resumes after a character literal and still rejects a following real macro, and rejects `core::option_env!`. + +Focused re-review of exact `f12499cba44f95733cd4d8bb006548aff8804858e` found no defect in the direct/namespaced compile-time-environment slice. That verdict predates the callable-alias generation and is not treated as current-head review evidence. + +A later CodeRabbit security review found a valid remaining bypass: Rust permits imports such as `use std::env as read_build_env;`, after which `read_build_env!(...)` executes the same compile-time environment macro without exposing the literal macro name at the call site. Structural RED `845d49bc608dbbb39c4e5de965426a549e54b639` adds direct `env!`, grouped `option_env!`, and raw-identifier alias hostile fixtures plus an exact `as _` control. Minimal repair `4830e4215b0340ac582c8afd9648b026ae4ff5d4` adds callable use-tree alias detection inside the focused supplemental contract while reusing the canonical source closure and shared Rust lexical helpers. The repair also covers a combining-mark continuation after `_` so Unicode `XID_Continue` input cannot be mistaken for the exact discard alias. + +Fresh lexical review then found that the direct `env` / `option_env` token itself still used Python `\w` boundaries even though the alias path had moved toward the shared Rust identifier-boundary owner. Structural RED `f4dd6e59c0a087a60053041d344c2db76df8bc3a` adds a supplemental control for a user macro whose identifier is `_` + U+0301 COMBINING ACUTE ACCENT + `env`; under the predecessor scanner, the combining mark is not Python `\w`, so the internal `env` substring is incorrectly treated as the built-in macro. Minimal repair `2f960cb82837abed1b4591d447cca5a609948def` removes the Python-regex token boundary from the focused owner and resolves direct macro token ends through `source_indirection._rust_identifier_token_end(..., allow_raw=True)`. Real `env!` remains fail closed, raw identifiers remain supported, and no second Rust lexer or Cargo topology scanner is introduced. + +The later source-indirection repair `1f323bca9494aa3e16d5f3daaf27a5b21277a9fb` removed `USE_TOKEN` and `AS_TOKEN` entirely from the shared owner. Structural successor RED `8b98d08ac00aa4f5e4b70fce780769b8a26308cc` extends the focused identifier-boundary contract with a valid `a\u0301use!(std::env as hidden_build_env)` macro-token control and a real `use std::env as hidden_build_env;` hostile alias. On the predecessor compile-time-environment supplement, callable-alias analysis still dereferenced the removed `source_indirection.USE_TOKEN` / `AS_TOKEN` names instead of the shared lexical API. Minimal repair `c06a36d2bf38dfee155743ce08fec5a711616f56` replaces both stale references with `_rust_identifier_token_end(..., "use")` / `_rust_identifier_token_end(..., "as")`, preserving the existing use-statement and discard-alias handling. This is an owner-consistency repair: the supplement now consumes the same Rust identifier boundary as direct macro, include/path/mod, and source-indirection alias detection rather than recreating or pinning a former regex surface. + +This identifier-boundary repair is source-semantic evidence only until the exact successor receives fresh review and hosted execution. No predecessor focused-review verdict is carried forward as proof for the new generation. + +## Security and buyer effect + +The contract prevents Git-reviewed Browser Session Rust source from silently binding artifact content to ambient build values through the two standard compile-time environment macros, whether invoked by their built-in spelling, a namespace-qualified spelling, or a callable `use` alias. At the same time, it now avoids rejecting a larger valid Rust identifier merely because it contains `env` or `use` after a Unicode identifier-continuation scalar. Keeping the supplement on the canonical lexical helper also prevents a shared-owner cleanup from silently disabling the compile-time-environment contract. This keeps the fail-closed contract tied to Rust lexical authority instead of Python's Unicode word-character table or stale compatibility symbols. + +This is necessary but not sufficient for reproducible release evidence. Runner environment, build-script output, proc-macro or declarative-macro expansion that synthesizes equivalent calls, generated source, direct compiler invocation, and externally injected Cargo environment remain CI/release supply-chain evidence surfaces unless separately attested. + +## Acceptance and rollback + +Acceptance for this generation requires all of the following on the reconciled exact head: + +- the focused contract passes with the existing Rust source-indirection, embedded-file, Cargo-environment, and trusted-adapter contracts; +- repository/security workflows run on the exact head and pass without gate weakening; +- current-head review confirms the supplemental contract consumes rather than duplicates canonical topology/lexical ownership and that direct and callable-alias handling match Rust identifier semantics; +- release evidence, if produced, binds the exact source tree, toolchain, environment-variable names and values that may affect compilation, producer identity for generated values, SBOM/provenance, independent reproducibility, and rollback. + +If the product later needs a compile-time environment value, do not delete the fail-closed rule. Replace it in the same reviewed change with a versioned contract that binds variable name, purpose, producer, canonical value or digest, secrecy classification, target/toolchain scope, invalidation semantics, SBOM/provenance linkage, independent reproducibility, and rollback. + +## References + +Rust Project. (2026). *env macro (Rust 1.98.1)*. The Rust Standard Library. https://doc.rust-lang.org/core/macro.env.html + +Rust Project. (2026). *option_env macro (Rust 1.98.1)*. The Rust Standard Library. https://doc.rust-lang.org/core/macro.option_env.html + +Rust Project. (2026). *Identifiers*. The Rust Reference. https://doc.rust-lang.org/reference/identifiers.html + +Rust Project. (2026). *Build scripts*. The Cargo Book. https://doc.rust-lang.org/cargo/reference/build-scripts.html diff --git a/docs/traceability/browser-session-rust-embedded-file-input-authority.md b/docs/traceability/browser-session-rust-embedded-file-input-authority.md new file mode 100644 index 000000000..7c383dd44 --- /dev/null +++ b/docs/traceability/browser-session-rust-embedded-file-input-authority.md @@ -0,0 +1,82 @@ +# Browser Session Rust embedded-file input authority + +## Status + +Implemented on PR #317 as a focused repository contract. This document records source-semantic evidence only; it is not hosted CI, protected-main integration, or release evidence. + +## Problem + +`include_bytes!` and `include_str!` are compile-time file inputs. Rust 1.98.1 documents that both macros locate a file relative to the current source file at compile time; `include_bytes!` places the file bytes in a `&'static [u8; N]`, while `include_str!` places UTF-8 file contents in a `&'static str`. + +Rust also resolves bang-style macros in the macro namespace, and `use` declarations can create aliases for imported macro names. The standard library re-exports `include_bytes` and `include_str` from `core`. Consequently, checking only the literal invocation spellings `include_bytes!(...)` and `include_str!(...)` is insufficient: `use core::include_bytes as read_blob; read_blob!(...)` selects the same compile-time file bytes while hiding the built-in macro name at the call site. + +Before the first generation, `tests/test_browser_session_rust_source_indirection_contract.py` governed `include!`, module/path indirection, and the shared Rust lexical helpers, but it did not classify `include_bytes!` or `include_str!`. Before the alias repair, the supplemental embedded-file contract classified direct and namespaced calls but not callable `use ... as ...` aliases. A reviewed production `.rs` file could therefore select additional file bytes that were not represented in the canonical production-source closure. + +For Browser Session, that is a provenance gap: the final artifact may contain compile-time-selected bytes even though the selected file itself is outside the source set inspected by the trusted-adapter/source-indirection contracts. + +## Decision + +Keep `tests/test_browser_session_trusted_adapter_boundary.py` as the single writer for production Cargo package/source topology and keep `tests/test_browser_session_rust_source_indirection_contract.py` as the shared Rust lexical/source-indirection owner. + +The focused supplemental contract, `tests/test_browser_session_rust_embedded_file_input_authority_contract.py`: + +- first consumes the existing source-indirection contract; +- consumes the canonical production source closure instead of rediscovering Cargo topology; +- reuses the shared trivia, raw-string, quoted-string, character-literal, `use`, `as`, and use-statement helpers; +- fails closed when lexical production source invokes `include_bytes!` or `include_str!`, including namespaced spellings such as `core::include_bytes!`; +- fails closed when a `use` tree gives either macro a callable alias, including grouped imports and aliases beginning with `_`; +- treats `use ... as _` as an unnameable import rather than callable alias authority, consistent with the Rust Reference; +- does not classify mentions inside Rust comments or string/character/raw-string literals as file-input authority. + +The policy is intentionally conservative about macro resolution. A locally shadowed macro named `include_bytes!` or `include_str!`, or a callable alias of those imported names, is still rejected until macro-expansion provenance is modeled. This avoids allowing name shadowing or aliasing to become a bypass around the compile-time file-input boundary. + +## RED → repair evidence + +Initial structural RED: `5cac6feeadb008e200c8590707f7f18d19f9c6c5`. + +The initial RED adds realistic workspaces with existing `unreviewed.bin` and `unreviewed.txt` files and requires the pre-existing source-indirection assertion to reject `include_bytes!(...)` and `include_str!(...)`. Initial repair `c163991ddc84fd519194cdae54643b252ec316d2` adds the direct/namespaced embedded-file classifier while delegating package/source discovery to the existing owners. + +Alias-bypass RED: `369b807db9988844626dcf2ea1f38eb67379e5e6`. + +That RED adds callable aliases for both built-ins: `use core::include_bytes as read_blob` and `use std::include_str as read_text`. The predecessor classifier sees the built-in name inside the `use` item without a following `!`, then sees only the alias at invocation, so both hostile workspaces pass when they must fail closed. + +Minimal alias repair: `ae1cf874a39ffd4b00a67216d6a2caa62e615721`. + +The repair stays inside the focused embedded-file contract, reuses the shared Rust lexical/use helpers, and classifies callable aliases without creating another Cargo topology or general Rust lexer owner. + +Alias-edge RED: `423c4088409215e30199943b7167150b3082fa3d`. + +Reviewing the first repair exposed an identifier-boundary bug: treating any alias beginning with `_` as the special underscore import would allow a callable alias such as `_read_blob`. Repair `b978c3c79ad0d0938bdb9d14c712693b9ed87417` distinguishes the exact unnameable `_` binding from ordinary identifiers beginning with `_`. Coverage successor `3f40f6662cf68c301579d3a70b3e76d0325eb705` adds grouped-use, exact-underscore, and comment controls without changing ownership. + +Focused review of exact `2c108fc14e9a2eaee79ea16219248c42aa1fd815` found one valid fixture-coverage gap rather than a classifier defect: the alias scanner consumes shared raw-string and simple-character-literal helpers, but the focused alias contract did not directly regress those lexical paths. Review-driven test-only repair `07841fbb4d84541758c796011dbcc402c6d40471` adds two separate fixtures: raw-string alias text must remain lexical data, and scanning must resume after a character literal so a following real aliased embedded-file input still fails closed. The classifier and ownership boundaries are unchanged by that repair. + +No Cargo topology, runtime browser behavior, linker authority, or cross-repository owner is duplicated by this generation. + +## Security and buyer effect + +The contract prevents Git-reviewed Browser Session Rust source from silently importing unmodeled compile-time file bytes through the standard embedded-file macros even when their invocation names are changed by `use` aliasing. This narrows artifact provenance to inputs that have an explicit reviewed contract rather than relying on the source file alone as evidence of what entered the binary. + +This is necessary but not sufficient for release provenance. Ambient filesystem contents, environment-driven paths, proc-macro or declarative-macro expansion that synthesizes equivalent file inputs, generated source, and direct compiler invocation remain CI/release supply-chain surfaces unless separately attested. + +## Acceptance and rollback + +Acceptance for this generation requires all of the following on the reconciled exact head: + +- the focused contract passes together with the existing Rust source-indirection and trusted-adapter contracts; +- repository/security workflows run on the exact head and pass without gate weakening; +- current-head review confirms the supplemental contract consumes rather than duplicates canonical topology/lexical ownership; +- release evidence, if a release is produced, records the exact source tree, toolchain, filesystem/input provenance, SBOM/provenance, and reproducible-build result. + +If the product later needs compile-time embedded files, do not delete the fail-closed rule. Replace it in the same reviewed change with a versioned manifest or equivalent contract that binds source selector, canonical path, content digest, producer/source identity, containment/symlink rules, target/toolchain compatibility, SBOM/provenance, independent reproducibility, invalidation, and rollback. + +## References + +Rust Project. (2026). *include_bytes macro (Rust 1.98.1)*. The Rust Standard Library. https://doc.rust-lang.org/stable/std/macro.include_bytes.html + +Rust Project. (2026). *include_str macro (Rust 1.98.1)*. The Rust Standard Library. https://doc.rust-lang.org/stable/std/macro.include_str.html + +Rust Project. (2026). *Use declarations*. The Rust Reference. https://doc.rust-lang.org/reference/items/use-declarations.html + +Rust Project. (2026). *Namespaces*. The Rust Reference. https://doc.rust-lang.org/reference/names/namespaces.html + +Rust Project. (2026). *Macros*. The Rust Reference. https://doc.rust-lang.org/reference/macros.html diff --git a/docs/traceability/browser-session-rust-include-lexical-authority.md b/docs/traceability/browser-session-rust-include-lexical-authority.md new file mode 100644 index 000000000..ee513402d --- /dev/null +++ b/docs/traceability/browser-session-rust-include-lexical-authority.md @@ -0,0 +1,45 @@ +# Browser Session Rust `include!` lexical authority + +Status: Draft repair evidence on the #317 Browser Session source-provenance lane. Hosted exact-head repository/security acceptance, whole-PR review, and protected-main shipment remain separate gates. + +## Problem + +OriginWeave deliberately fails closed when reviewed production Rust source uses `include!`, because the macro parses another file into the surrounding crate at compile time and therefore extends the executable source-input closure. The existing detector searched the raw source text with `INCLUDE_TOKEN.finditer(...)` and likewise searched raw text for `use ... include as ...` aliases. That classified `include!(...)` or `use core::include as ...` appearing only inside comments or string literals as executable authority. + +That behavior is conservative but incorrect: Rust non-doc comments are lexically whitespace, and string/character/raw-string contents are literal tokens rather than macro invocations. A provenance guard that cannot distinguish lexical data from executable syntax creates false-positive security failures and makes reviewed documentation/log strings an accidental build-authority gate. + +## Evidence and repair + +- Structural RED `6bf90e950ebbe09f28f56d4e6665433265cb238d` adds independent controls requiring line-comment and ordinary-string `include!(...)` text to remain lexical data while a real `include!(...)` invocation still fails closed. +- Minimal causal repair `6ad8f195e1bc9c649024b1e29244f00313d9a3e9` keeps ownership in `tests/test_browser_session_rust_source_indirection_contract.py`. `_has_include_macro()` and the outer `_has_aliased_include_import()` scan reuse the existing Rust trivia/raw-string/quoted-string/character-literal helpers before recognizing `include` or `use` tokens. No Cargo topology, production crate, or browser-domain authority is duplicated. +- Edge-case successor `2f3311b47509e86421917099c678919f39c43d01` adds raw-string false-positive coverage, commented `use core::include as ...` coverage, and character-literal scan resumption before a real hostile `include!`. +- Focused review of `4e4e217d010dbd2ad557d700ece931654b3791d4` found one remaining lexical false positive: once a real `use` declaration was captured, its inner use-tree still used raw `INCLUDE_TOKEN.finditer(use_tree)`, so `use core::{ /* include as hidden_include */ fmt };` was misclassified as alias authority. +- Review-driven RED `6134c1b5583b05f6847078e33ded2770b410e8ed` adds that grouped-use comment control. Repair `be359649734acc0173cb3c61802f7905f67d7980` replaces the inner raw scan with the same trivia/literal-aware cursor discipline while preserving real callable alias rejection. + +The repair is intentionally lexical rather than pathname-based. Allowlisting a path would not fix the category error: comment/literal text must never become authority regardless of its spelling, while a real `include!` remains provenance-relevant even for an in-repository path until the compiler-derived source-input contract explicitly models it. + +## Invariants + +1. A lexical `include!` macro invocation in a reviewed production source fails closed until an explicit source-provenance contract admits the included file. +2. A lexical `use ... include as ` declaration fails closed under the same authority boundary, including grouped use trees. +3. Line/block comments and ordinary/raw string or character literal contents do not create source-input authority merely because their text resembles `include!` or an alias declaration, including comments nested inside a real use tree. +4. Scanning resumes after a literal token and still rejects a following real `include!` invocation. +5. `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology; this repair changes only lexical classification inside the existing Rust source-indirection owner. + +## Rejected alternatives + +- **Keep raw-text matching because it is safer.** Rejected. False positives make comments and documentation semantically equivalent to compiler inputs, which is not a defensible security boundary and creates pressure to add ad hoc suppressions. +- **Add pathname exceptions.** Rejected. The defect is lexical classification, not path selection; exceptions would weaken the real `include!` boundary without fixing comment/literal handling. +- **Create another repository-wide Rust scanner.** Rejected. That would violate single-writer ownership and duplicate the existing shared lexer/topology contracts. + +## Acceptance + +This slice is acceptable only when both RED generations are demonstrably failing on their predecessors and GREEN on the corresponding repairs, the current production-source contract remains fail closed for real `include!` and callable aliases, current-head static review finds no ownership or lexer regression, and hosted repository/security checks are independently satisfied after the central workflow prerequisite chain permits them. Command acknowledgement or a static review response is not hosted GREEN. + +## Authoritative references + +Rust Project. (2026). *The Rust Reference: Comments*. https://doc.rust-lang.org/reference/comments.html (retrieved September 19, 2026). Non-doc comments are tokenized as whitespace. + +Rust Project. (2026). *The Rust Reference: Macros — macro invocation*. https://doc.rust-lang.org/reference/macros.html (retrieved September 19, 2026). A macro invocation has the token form `SimplePath ! DelimTokenTree`. + +Rust Project. (2026). *Macro `include`*. https://doc.rust-lang.org/nightly/core/macro.include.html (retrieved September 19, 2026). `include!` parses another file into the surrounding context at compile time and resolves the path relative to the current file. diff --git a/docs/traceability/browser-session-rust-native-link-attribute-authority.md b/docs/traceability/browser-session-rust-native-link-attribute-authority.md new file mode 100644 index 000000000..29ffaa38d --- /dev/null +++ b/docs/traceability/browser-session-rust-native-link-attribute-authority.md @@ -0,0 +1,93 @@ +# Browser Session Rust native-link attribute authority + +## Problem + +The Browser Session production-source closure is reviewed, but reviewed Rust source can itself select native-library bytes that are not part of that source closure. Rust's built-in `#[link(...)]` attribute on an `extern` block names a native library for rustc to link. The Rust Reference defines `dylib` as the default, and also supports `static`, macOS `framework`, and Windows `raw-dylib`; link modifiers can further change how those native bytes participate in the artifact. + +The existing repository-owned Cargo authority contract already fails closed on Git-owned `-L`, `-l`, `--library`, `--extern`, linker positional inputs, and equivalent modeled forwarding paths. That does not prove the artifact selected by a source-level `#[link(name = ...)]`. A reviewed Rust source tree therefore did not, by itself, prove the final Browser Session native dependency closure. + +## Constraint + +`tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. `tests/test_browser_session_rust_source_indirection_contract.py` remains the lexical owner for Rust attribute extraction and source-indirection trivia/literal handling. This supplemental contract consumes both; it does not rediscover workspace topology or introduce a second Rust attribute parser. + +This is an exact-tree provenance rule, not a claim that Rust FFI or the `link` attribute is unsafe as a language feature. Until a versioned native-artifact contract exists, a source-selected native library would depend on bytes resolved from the target toolchain/search environment rather than on the reviewed OriginWeave source closure. + +## RED → repair + +Structural RED **`9024d61487d7a0127050b97932589e2044ddb019`** adds hostile fixtures for: + +- direct `#[link(name = "review_bypass", kind = "static")]`; and +- nested `#[cfg_attr(unix, link(name = "review_bypass"))]`. + +The RED intentionally delegates to the pre-existing Rust-source provenance contract. That predecessor does not classify source-level native-library selection, so both hostile cases are expected to remain unblocked at that generation. Positive controls keep the word `link(...)` inside a documentation string and `#[unsafe(link_section = ...)]` outside this native-library rule. + +Minimal repair **`e3571e51db8f3f0dadbbc54baa5de1813e2c66cb`** stays in the supplemental contract. It reuses the canonical production-source closure and the existing balanced Rust attribute/trivia/literal helpers, then fails closed when an attribute meta tree contains a real `link(...)` meta item. No Cargo topology scanner, linker parser, or FFI runtime implementation is duplicated. + +Focused review of exact **`e7258ab8136b019b1570e24de8528aa7ba0cb963`** then found a valid root lexical defect in the shared attribute extractor: the outer `_rust_attribute_bodies()` search used `text.find("#", ...)` before lexical filtering. As a result, `#[link(...)]` text inside a line comment or ordinary string could be extracted as if it were a real attribute and rejected by the supplemental contract. This was a source-level false positive, not a reason to weaken the native-library authority rule or add a parallel parser. + +Review-driven RED **`5ae81cc7657327ca901d968dba94389c723200cc`** adds explicit controls for both forms: + +- `// #[link(name = "review_bypass")]`; and +- an ordinary Rust string containing `#[link(name = "review_bypass")]`. + +Canonical root repair **`efb8f1da2f1f3aa98d947e4c6c611e58408cd09b`** changes the shared Rust-source lexical owner instead of working around it in the native-link contract. `_next_rust_attribute_marker()` now scans for the next attribute marker while skipping whitespace/comments, raw strings, conventional strings, and simple character literals with the already-owned lexical helpers; `_rust_attribute_bodies()` consumes that marker. The same repair adds path-attribute controls proving that `#[path = ...]` text inside a line comment or ordinary string is not source indirection. The existing balanced attribute-body parser remains authoritative for both source-indirection and native-link consumers. + +The intended post-repair semantics are therefore: + +- real direct `#[link(...)]`: fail closed; +- real nested `cfg_attr(..., link(...))`: fail closed; +- `link(...)` inside a real attribute string: allowed unless another modeled meta item grants authority; +- `#[link(...)]` text inside source comments/strings: ignored as lexical data; +- `link_section`: outside this native-library-selection rule; and +- macro-expanded equivalent attributes: still a residual surface requiring compiler-derived or macro-expansion evidence. + +## Decision + +Browser Session production Rust source must not introduce `#[link(...)]` native-library selection until the same reviewed delta defines the selected artifact contract. `cfg_attr(..., link(...))` is governed by the same rule because target configuration can make that native dependency active only on a subset of release targets. + +If a native FFI dependency becomes product-required, the allow contract must identify at least: + +- logical library name and link kind/modifiers; +- exact artifact digest and producer/build provenance; +- target triple, ABI, architecture, and toolchain identity; +- bounded library search roots and symlink/realpath containment; +- static/archive member or dynamic/import-library identity as applicable; +- SBOM and release provenance linkage; +- an independent reproducibility check on a clean environment; and +- invalidation and rollback behavior when the artifact or toolchain changes. + +A library-name-only or path-only allowlist is insufficient because the same `name` can resolve to different bytes under a different sysroot, linker search root, runner image, or target platform. + +## Security and buyer effect + +The rule closes a source-authored native supply-chain path that is independent of Cargo `rustflags`. A code review that sees `#[link(name = "foo")]` can prove intent, but without artifact identity it cannot prove which `foo` bytes entered a static artifact or which runtime library/import library a release depends on. For enterprise browser runtime evidence, that distinction is material to SBOM completeness, provenance, reproducibility, incident response, and rollback. + +The contract deliberately does not ban ordinary `unsafe extern` declarations that do not select a native library. FFI declarations and ABI safety remain Rust/runtime review concerns; this slice owns only native-library artifact selection. + +The shared lexical repair also matters to buyer-visible evidence quality: comments and diagnostic strings must not create phantom dependency findings that could turn a repository policy gate into a source-text keyword filter rather than a reviewable Rust authority boundary. + +## Residual surfaces + +Environment/direct-CLI `-L`/`-l` injection, toolchain/sysroot composition, target-default native libraries, linker search roots, deployment-time dynamic loader state, and non-Rust native dependencies remain CI/release supply-chain surfaces. They are not converted into repository-owned authority by this contract. + +Source or macro mechanisms that can synthesize attributes after parsing require compiler-derived or macro-expansion evidence if they can introduce equivalent native-library authority; this lexical contract does not claim macro-expansion completeness. + +## Primary evidence + +Rust Project. (2026). *External blocks: The `link` attribute*. The Rust Reference. https://doc.rust-lang.org/nightly/reference/items/external-blocks.html + +The Reference states that `#[link]` specifies the native library the compiler links for an external block, defines `dylib`, `static`, `framework`, and `raw-dylib`, and documents modifiers such as `whole-archive` and `verbatim`. + +Rust Project. (2026). *Command-line arguments: `-l`*. The rustc book. https://doc.rust-lang.org/nightly/rustc/command-line-arguments.html + +The rustc book documents the native-library `-l` model and explicitly notes that library kind and modifiers can also be specified with a `#[link]` attribute. This connects the source attribute to the same native-input semantics already governed for Git-owned Cargo flags. + +Rust Project. (2026). *Foreign function interface: Linking*. The Rustonomicon. https://doc.rust-lang.org/nomicon/ffi.html + +The Rustonomicon explains that the `link` attribute instructs rustc how to link native libraries and that static native libraries can be incorporated into output artifacts while dynamic dependencies propagate to the final artifact boundary. + +## Verification state + +The initial RED, native-link repair, review-driven false-positive RED, canonical lexical root repair, and this traceability successor are structurally present on the active #317 lineage. The focused review finding is treated as valid and repaired at its canonical lexical owner rather than suppressed. + +The environment available to this writer cannot resolve `github.com` for a local clone, and the exact head still has no pull-request-triggered hosted workflow execution. Therefore no executable GREEN, full current-head review closure, protected-main integration, 100% owned coverage closure, or release readiness is claimed from this dossier alone. diff --git a/docs/traceability/browser-session-rust-response-file-authority.md b/docs/traceability/browser-session-rust-response-file-authority.md new file mode 100644 index 000000000..f8cb01f78 --- /dev/null +++ b/docs/traceability/browser-session-rust-response-file-authority.md @@ -0,0 +1,32 @@ +# Browser Session Rust response-file authority + +## Problem + +Repository-owned Cargo `rustflags` and `rustdocflags` are reviewed compiler/documentation input surfaces. Both `rustc` and `rustdoc` support a top-level `@path` argument that opens a UTF-8 file and loads additional command-line options from it, one option per line. Treating only the visible Cargo flag list as authority therefore leaves an opaque indirection path: a Git-owned response file can introduce `--extern`, `--sysroot`, `-L`, `-l`, codegen linker selection, or other compiler inputs after the repository contract has inspected the outer configuration. + +This is distinct from linker response files passed through `-Wl,`, `--for-linker=`, or `-Xlinker`. Those are already handled by the linker-argument classifier. This contract closes the rustc/rustdoc top-level response-file boundary. + +## Authority and constraint + +`tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. `tests/test_browser_session_cargo_compiler_authority_contract.py` owns repository-selected compiler/rustdoc execution and input authority. Supplemental tests consume that owner rather than rediscovering Cargo topology. + +A top-level Cargo `rustflags` or `rustdocflags` argument whose first character is `@` is fail-closed. The rule applies to both `[build]` and `[target.*]` configuration. An `@` appearing later inside an ordinary argument is not a response-file selector and is not rejected by this rule. + +## RED → repair evidence + +RED `f031bbc6154567c2b641879d00fa49d3412bc739` adds `tests/test_browser_session_rust_response_file_contract.py`. It covers build/target `rustflags` and build/target `rustdocflags` with top-level `@tools/...args` hostile fixtures while retaining an ordinary argument containing an internal `@` as a control. + +Repair `0c3d76ae214328c124fab8a7c8adcb4d2452a493` extends the existing `_rustc_argument_extends_external_inputs()` classifier. No new Cargo topology/config scanner is introduced; the existing build/target rustflags/rustdocflags call sites all inherit the same fail-closed rule. + +## Security effect + +A reviewed Cargo config can no longer hide compiler or rustdoc execution/input authority behind an opaque top-level response file. The repair preserves the existing explicit classifiers for `--extern`, `--sysroot`, `-L`, `-l`, rustc codegen linker/tool selection, and linker-level response files rather than replacing them with a separate policy path. + +This is a repository-source contract, not proof of the ambient execution environment. `RUSTFLAGS`, `CARGO_ENCODED_RUSTFLAGS`, `RUSTDOCFLAGS`, `CARGO_ENCODED_RUSTDOCFLAGS`, direct command-line arguments, ancestor or user Cargo configuration, runner-installed toolchains/sysroots, and Cargo's own internally generated argument files remain CI/release/runtime provenance surfaces. Those surfaces must be controlled by the execution/release owner rather than inferred from Git source closure. + +## Primary references + +- The Rust Project. (2026). *The rustc book: Command-line arguments — `@path`: load command-line flags from a path*. https://doc.rust-lang.org/rustc/command-line-arguments.html#path-load-command-line-flags-from-a-path +- The Rust Project. (2026). *The rustdoc book: Command-line arguments — `@path`: load command-line flags from a path*. https://doc.rust-lang.org/rustdoc/command-line-arguments.html#path-load-command-line-flags-from-a-path + +Both references specify that `@path` opens the named file and reads command-line options from it, one option per line, using UTF-8 with Unix or Windows line endings. diff --git a/docs/traceability/browser-session-rust-source-indirection.md b/docs/traceability/browser-session-rust-source-indirection.md new file mode 100644 index 000000000..d8ed47607 --- /dev/null +++ b/docs/traceability/browser-session-rust-source-indirection.md @@ -0,0 +1,96 @@ +# Browser Session Rust source-indirection provenance + +Status: active PR evidence only. This contract does not claim protected-main or release acceptance. + +## Problem + +The Browser Session trusted-adapter boundary already derives one canonical Cargo production package/source closure and rejects repository-external or dangling source objects. That is not sufficient by itself because reviewed Rust source can cause `rustc` to parse additional source bytes through language-level indirection. + +Four related forms matter here: + +- `include!(...)`, `include![...]`, and `include! {...}` parse another file as an expression or item. Rust macro invocation syntax permits all three delimiter forms, and the included path is relative to the source file containing the invocation. +- `#[path = "..."] mod ...;` changes the source file used for an outlined module. Rust documents the path attribute as a module-source filename override whose relative interpretation depends on the module location. +- `#[cfg_attr(..., path = "...")]` can conditionally synthesize the same `path` attribute. A direct `#[path]`-only lexical check therefore does not cover the full Rust attribute surface. +- A `mod` item can cause the compiler to load another Rust file. Cargo's ordinary `src/**/*.rs` review already contains default module trees conservatively, but a custom Cargo target whose crate root lives outside `src/` can load sibling module files that are not in that closure. Rust permits comments and other trivia between grammar tokens, Unicode XID identifiers, and raw identifiers, so a security contract must not encode a narrower hand-written identifier/module grammar and call it complete. + +Rust comments are lexical trivia rather than `\s`-only whitespace. This matters for both attributes and macro invocation punctuation. A valid source-loading attribute can spell its meta item as `#[path /* reviewed trivia */ = "nested.rs"]`, and a valid macro invocation can separate `include`, `!`, and its delimiter with non-doc comment trivia. A detector that requires `include\s*!\s*(` or `path\s*=` therefore recognizes a grammar narrower than Rust's. Likewise, a naive `[^\]]*` attribute capture can be truncated by `]` inside a comment or string even though that byte is not the attribute's closing delimiter. + +Raw identifiers are part of the same source-provenance surface. The Rust Reference defines a raw identifier as `r#` plus an identifier/keyword and states that the `r#` prefix is not part of the actual identifier. Macro invocations resolve a `SimplePath`, whose path segment admits an `IDENTIFIER`, so `r#include!(...)` names the same underlying `include` identifier through raw spelling. A detector that deliberately excludes `include` when immediately preceded by `#` therefore leaves a valid source-loading spelling outside the fail-closed contract. + +Macro renaming is also source provenance. Rust `use` declarations create local synonymous bindings, can import macro names, and permit `as` aliases. Because `include` is exported from `core`, `use core::include as embed; embed!("...")` can invoke the same source-loading macro without any later literal `include!` token. A direct-invocation-only scanner therefore has a second name-resolution bypass even after ordinary/raw spelling and comment trivia are covered. + +Primary references: + +- Rust `include!` macro: https://doc.rust-lang.org/stable/std/macro.include.html +- Rust `core::include` macro export/source: https://doc.rust-lang.org/core/macro.include.html +- Rust Reference, macro invocation syntax: https://doc.rust-lang.org/reference/macros.html#macro-invocation +- Rust Reference, use declarations and aliases: https://doc.rust-lang.org/reference/items/use-declarations.html +- Rust Reference, namespaces and macro imports: https://doc.rust-lang.org/reference/names/namespaces.html +- Rust Reference, module source filenames and `path` attribute: https://doc.rust-lang.org/reference/items/modules.html#module-source-filenames +- Rust Reference, conditional attributes with `cfg_attr`: https://doc.rust-lang.org/reference/conditional-compilation.html#the-cfg_attr-attribute +- Rust Reference, identifiers and raw identifiers: https://doc.rust-lang.org/reference/identifiers.html +- Rust Reference, comments: https://doc.rust-lang.org/reference/comments.html +- Rust Reference, attributes: https://doc.rust-lang.org/reference/attributes.html +- Rust 2018 Edition Guide, module file layout: https://doc.rust-lang.org/edition-guide/rust-2018/path-changes.html#no-more-modrs + +Without an explicit contract, a future lifecycle adapter could keep its crate, manifest, and declared custom target inside the exact Git review root while compiling additional Rust source not represented by the canonical production-source closure. That would weaken the same provenance boundary used for `DisposableContextPort`, `bind_lifecycle_port`, dependency, and source-containment review. + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py::_workspace_production_sources()` remains the single writer for Cargo production package/source topology. +- This contract consumes that closure; it does not reimplement Cargo workspace, dependency, target, build-script, or source-override discovery. +- Existing `crates/originweave-core/src/root.rs` intentionally uses `#[path = "lib.rs"]`. That exact source/attribute pair is the only currently reviewed path-attribute exception. +- Default `src/` module trees are already conservatively included by the canonical `src/**/*.rs` closure. The custom-target module guard is therefore limited to reviewed production sources outside every production package's default `src/` directory. +- The temporary custom-target guard intentionally over-approximates rather than reproducing Rust's module grammar. Any lexical `mod` token in a custom target root outside default `src/` fails closed. That may reject a harmless inline module or textual occurrence, but it removes identifier/trivia/visibility grammar gaps until compiler-derived source-input provenance replaces the heuristic. +- Rust attribute and `include!` review must preserve lexical token boundaries. Non-doc line/block comments, including nested block comments, are treated as trivia between Rust tokens; comment/string contents do not terminate the surrounding attribute token tree. +- Raw-identifier spelling must not create a second identity for a source-loading macro. Ordinary `include` and raw `r#include` are the same fail-closed provenance surface. +- Import aliases must not create a third identity for the same macro. A named `use ... include as alias` binding is fail-closed until compiler-derived macro/source provenance replaces the lexical contract; `as _` is not treated as callable alias authority because it creates no name that can be invoked later. +- No future BiDi adapter path or Rust source indirection is pre-authorized. + +## Decision + +Until compiler-derived source-input provenance is modeled, production Rust source fails closed on `include!` regardless of whether the macro path uses ordinary `include` or raw-identifier `r#include`, regardless of whether the invocation uses parentheses, brackets, or braces, and regardless of Rust whitespace/comment trivia between the identifier token, `!`, and the opening delimiter. The detector locates either spelling of the same underlying identifier and advances through the same nested non-doc-comment trivia skipper already used by the source-indirection contract before validating `!` and one of the three macro delimiters. It does not broaden the match to longer identifiers and does not authorize any source path. + +The same contract also fails closed when a Rust `use` tree gives `include` a callable alias. It scans `use` declarations through their semicolon while ignoring Rust comment trivia, then rejects a named `include as ...` binding. This covers direct and grouped use trees without trying to implement general macro name resolution. Ordinary direct imports remain covered by the later literal `include!` invocation; an underscore import is not a callable alias and is not rejected by this alias-specific rule. This remains a temporary lexical security boundary, not a claim of compiler-equivalent name resolution. + +Any Rust attribute containing a `path` meta item followed by valid Rust trivia and `=` is treated as source-indirection review surface, including `cfg_attr`-generated `path`. Attribute bodies are extracted as balanced bracket token trees while skipping Rust whitespace, line comments, nested block comments, normal/raw string literals, and simple character literals for delimiter purposes. This scanner is deliberately limited to locating reviewed path-bearing attribute surfaces; it does not claim to parse Rust modules, name resolution, or macro expansion. The contract uses an exact-tree allowlist of `(source_path, normalized_attribute_body)`. The allowlist must equal the path-attribute surfaces found on the current OriginWeave production-source closure, so it cannot reserve absent future adapter paths. The current exact allowlist contains only: + +`crates/originweave-core/src/root.rs` → `path = "lib.rs"` + +For a reviewed custom Cargo target outside a production package's default `src/` tree, the temporary contract no longer tries to prove that a particular `mod` spelling is outlined rather than inline. Any lexical `mod` token is a provenance stop. This deliberately conservative rule closes changes in identifier spelling and Rust trivia such as `mod r#type;`, `mod 관찰;`, or `mod /* comment */ helper;` without taking ownership of Cargo topology or pretending a regex implements the Rust parser. Ordinary `src/` module trees remain outside this guard because their sibling files are already enumerated by the canonical source closure. + +Any additional path-bearing attribute, `include!` form outside the current fail-closed policy, aliased source-loading macro, or custom-target module source must arrive in the same reviewed delta that explains and tests its source provenance. A future filesystem or macro-name indirection must not silently widen the Browser Session TCB. + +## RED → repair evidence + +- `d8741e7a88b45c5c926963801493afc4ec14462a` added hostile `include!` and parent-traversal `#[path]` fixtures while the contract body was deliberately inert, exposing the missing fail-closed behavior. +- `f42b1012306290311cd240671376096ca107bae2` added the first source-indirection guard consuming the canonical production-source closure. +- `5068055be9acc3c8dc5ebd167a2b26099cbdacd4` tightened `#[path]` handling from path-shape heuristics to an exact current-tree allowlist, preserving the existing reviewed `originweave-core/src/root.rs -> lib.rs` exception while rejecting any new path attribute until reviewed. +- `a6f6d545df4d890edebe82ddc78fe4f36a0a1cfd` added a hostile `cfg_attr(..., path = ...)` fixture and generalized discovery from only direct `#[path]` spellings to any Rust attribute carrying `path =`, preventing conditional compilation from bypassing the exact-tree review surface. +- Focused CodeRabbit review of exact `72fb8b410d05a252bda7da81a428fcdbcfa31f4e` found a valid P1: the first `include!` detector matched only the parenthesized form even though Rust macro invocations also admit bracket and brace token trees. +- `cf9a1902d9c4c184121850ea77cf3f1ca4ce29ee` added hostile `include! {...}` and `include![...]` regressions without changing the parenthesized-only detector, preserving a structural RED for both bypasses. +- `52dae82b4d4a26ae56cb81913e6d8e1daf7a6e19` repaired the detector to recognize all three valid macro delimiter forms while keeping the same fail-closed error and canonical Cargo source closure. +- `d823d04a105fa8234077039d96cc168cf942bc8d` added a hostile custom `[lib].path = "runtime/lifecycle_adapter.rs"` whose crate root declares `mod helper;` and whose sibling `runtime/helper.rs` is outside the canonical `src/**/*.rs` closure. The pre-repair contract did not reject that source expansion, preserving the structural RED. +- `eb2ea168fd951bbc817f24cd08fb2b0b5805a775` added the first custom-target module guard while preserving the positive ordinary `src/lib.rs -> mod nested;` fixture. +- Focused CodeRabbit review of exact `f28e96f5dca746adab2df3fb909bd205d13947ed` found a valid P1: the first guard missed raw identifiers such as `mod r#type;`. +- `8d396db28df3e9757a1f3ee96eb65bca15a16e4f` preserved that raw-identifier finding as a hostile structural RED, and `aabd724d0d41a526ca41ade4e47349b94c0151f5` repaired the reported raw spelling. +- Focused CodeRabbit review of exact `8ba03a5cf3022dbe21c9f4e1b0443e7481941661` found the same hand-written grammar was still ASCII-only even though Rust identifiers can be Unicode. +- `aa90b3ef465785ab0250097dedbc917dc2ce9cc6` preserved a hostile Unicode module RED (`mod 관찰;`), and `e9dfcbefcc7d2ed022564a76edd3715f1f30d071` removed the ASCII identifier assumption. +- Focused CodeRabbit review of exact `fc0275ca9099955819777b72e73b5c25891e826a` found one remaining P1 in the same grammar-emulation approach: valid Rust trivia can occur between `mod` and its module-name token, so a detector that requires direct horizontal whitespace before an identifier remains bypassable. +- `130e9512d8a96db782de0a7b98e490b6db17a3c6` preserves that finding as a structural RED with `mod /* reviewed trivia */ helper;` while leaving the prior detector unchanged. +- `bb83cf9571c2091bbb088176a9881de5fb46739b` removes the fragile hand-written module-name grammar. For custom target roots outside default `src/`, the temporary gate now fails closed on any lexical `mod` token. This is intentionally conservative and temporary; it closes trivia/raw/Unicode spelling classes without taking ownership of the Rust parser or Cargo topology. +- `dce0c96fb8a05cce5605ecf42df858c16276099d` preserved a new structural RED showing that Rust block-comment trivia between `path` and `=` bypasses the prior `path\s*=` detector. +- `781f3b1db14bf7591091cb7be5bb552e6e5497b1` broadened that RED to nested block comments, line-comment trivia, and a closing bracket inside a comment, demonstrating that both the path-meta regex and the `[^\]]*` attribute-body regex were narrower than Rust lexical rules. +- `04e08a48fb7572860b0de564bdbf615ad2da5186` repaired the attribute review surface without changing Cargo topology: balanced attribute token trees now ignore comment/string delimiters correctly, and `path` followed by Rust trivia and `=` enters the exact-tree allowlist review surface. +- `23b7b241c3e9389a43a359a14c4dfa74036d8829` preserved a new structural RED for macro-invocation trivia: block comments between `include` and `!`, block comments between `!` and the opening delimiter, and line-comment trivia between `include` and `!` all bypass the predecessor `include\s*!\s*[([{]` detector even though Rust treats non-doc comments as whitespace between grammar tokens. +- `bf132fb7b2c2d1a2fdae312962e2f0e0380fc1a5` repaired that gap by replacing the whitespace-only macro regex with token-plus-trivia recognition that reuses the existing nested Rust comment skipper before `!` and before the opening delimiter. The Cargo source closure and fail-closed policy are unchanged. +- `783bbc614d08c3a9299849524e3dbda8545ab29f` preserved a new structural RED for raw-identifier macro spelling. The predecessor detector intentionally rejected `include` when immediately preceded by `#`, so `r#include!("../generated_adapter.rs")` was outside the fail-closed include provenance stop even though Rust raw identifiers retain the same underlying identifier. +- `5c3b86091199f436374111d6a4056d47c13c1448` repaired the detector minimally by admitting an optional `r#` prefix as part of the include identifier token. Existing comment-trivia and three-delimiter handling remains unchanged; longer identifiers remain excluded and no path is allowlisted. +- `2c13993f24b07a7048d7879e594e72a744eeb95f` preserved a new structural RED for macro-name aliasing: a production source imports `core::include as embed` and invokes `embed!("../generated_adapter.rs")`. The predecessor direct-invocation detector sees the `include` token only inside the `use` tree, where no `!` follows it, and therefore does not stop the aliased source load. +- `4b8dd0832c6965c7e887b7538caa2f4ddc1d917c` is the minimal causal repair. The source-indirection contract now recognizes named `include as alias` bindings inside Rust `use` declarations, including grouped use trees and comment trivia, while preserving the existing direct/raw invocation detector and treating `as _` as non-callable. Cargo topology and source containment remain owned by the existing canonical closure. + +This evidence is structural/static on a Draft branch. It is not executable exact-head GREEN and does not replace the required parent-lineage, repository/security, or real-Chromium acceptance gates. + +## Follow-up + +Replace the temporary custom-target lexical stop and hand-maintained attribute/macro-source review with compiler-derived or equivalently exact source-input provenance before OriginWeave needs legitimate custom-target module trees or broader source-generating attributes/macros. The replacement must cover the actual bytes compiled by Rust across supported target configurations, preserve repository containment and immutable provenance, distinguish inline modules from source-loading outlined modules without ad-hoc grammar drift, cover macro name resolution/re-exports without lexical approximation, and arrive with hostile fixtures before any allowlist widening. \ No newline at end of file diff --git a/docs/traceability/browser-session-rustc-linker-toolchain-selection.md b/docs/traceability/browser-session-rustc-linker-toolchain-selection.md new file mode 100644 index 000000000..5824dd69a --- /dev/null +++ b/docs/traceability/browser-session-rustc-linker-toolchain-selection.md @@ -0,0 +1,43 @@ +# Browser Session rustc linker toolchain selection traceability + +Status: Draft contract evidence on PR #317. This document does not claim executable repository/security GREEN. + +## Problem + +The existing Cargo compiler-authority contract already fails closed when Git-owned Cargo configuration selects `build.rustc`, rustc wrappers, `build.rustdoc`, target `linker`/`runner`, `-C linker=...`, and modeled driver/linker execution extensions. Rustc still exposes codegen options that can change which linker or auxiliary executable is invoked without spelling `linker=`. + +The rustc codegen reference states that `link-self-contained` controls whether linking uses Rust-shipped libraries and objects and also controls which binary is used for the linker. The same reference documents `linker-features`; on `x86_64-unknown-linux-gnu`, the `lld` feature controls whether rustc tries to use an LLD linker and may select either the system linker or the self-contained `rust-lld` path. `linker-flavor` determines which linker flavor rustc uses and, when no explicit `-C linker` is supplied, determines the linker to use. The `dlltool` option accepts a path to the dlltool executable rustc invokes for `windows-gnu` raw-dylib import-library generation. These are execution-authority choices, not ordinary optimization flags. + +A repository-owned `.cargo/config.toml` or `.cargo/config` can supply these options through `[build].rustflags`, matching `[target].rustflags`, and equivalent rustdoc flag surfaces. Therefore a reviewed source tree can keep the nominal Cargo target and omit `target.<...>.linker` while still changing the effective native tool selected by rustc. + +Primary reference: Rust Project, *The rustc book: Codegen options*, `link-self-contained`, `linker-features`, `linker-flavor`, `dlltool`, and `linker` sections: https://doc.rust-lang.org/rustc/codegen-options/ + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology and dependency-source override discovery. +- `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the shared parser for Git-owned Cargo compiler/linker execution authority. This slice does not introduce a second Cargo flag scanner. +- No self-contained linker, system LLD, `rust-lld`, alternate linker flavor, custom dlltool, custom linker feature policy, or future adapter implementation is pre-authorized by this repair. +- Environment/toolchain-owned native-tool selection remains a CI/supply-chain concern. This contract covers Git-owned Cargo configuration only. +- Ordinary codegen options that do not select or alter the modeled compiler/linker/native-tool execution boundary remain allowed. + +## RED + +Commit `292f62f78b5f10548e7a9745f657fc732d4e8e77` adds `tests/test_browser_session_linker_toolchain_selection_contract.py` with hostile Cargo configurations for `[build].rustflags = ["-C", "link-self-contained=+linker"]` and target-scoped `rustflags = ["--codegen=linker-features=+lld"]`. The predecessor shared parser recognized direct `linker=` and flag-derived driver/linker extensions but ignored both rustc-managed linker-selection options. + +Commit `750650e87c2e3c01655b14a11cc6da3e1fd33b13` extends that same hostile contract after the first repair and preserves two additional source-semantic REDs: `-C linker-flavor=ld.lld` and `--codegen=dlltool=tools/review-bypass-dlltool`. Both can alter a native executable selected by rustc without using the already-modeled direct `linker=` setting. The `debuginfo=1` control remains allowed. + +## Decision and repair + +Commit `7523b391b1e296a88115d9411619ac22ad2d0a42` extends the existing `_flags_select_linker` parser only. `link-self-contained` and `linker-features`, in exact or `=` form after `-C` / `--codegen`, are classified as execution-authority changes and fail closed through the existing Cargo compiler-authority error path. + +Commit `e9a3e85110153a667bf3dd6025bf57d08975ead6` extends the same shared parser to `linker-flavor` and `dlltool`. It does not infer a safe flavor or allowlist a repository-selected dlltool path; either surface requires a separate explicit provenance contract before it can enter the reviewed production build boundary. + +The repair intentionally does not parse target-specific linker heuristics or infer which LLD/dlltool binary would be chosen. A future exception requires an explicit, versioned toolchain provenance contract that identifies the exact native-tool artifact, selection semantics, integrity evidence, and rollback behavior on the same reviewed tree. + +## Security effect + +Git-owned Cargo configuration can no longer switch from the reviewed nominal linker path to rustc-managed self-contained/system LLD selection, another inferred linker flavor, or a repository-selected dlltool through these codegen options without an explicit Browser Session provenance contract. The boundary remains deterministic and fail closed while unrelated codegen options stay available. + +## Residual surfaces + +This repair does not claim full linker-input provenance. Positional native inputs and implicit linker scripts, library/search-path selection, non-GNU control-file mechanisms, command-line `cargo rustc` flags, environment variables, rustup/toolchain composition, and target-specific external toolchain scripts remain separate review surfaces. diff --git a/docs/traceability/browser-session-rustc-llvm-plugin-authority.md b/docs/traceability/browser-session-rustc-llvm-plugin-authority.md new file mode 100644 index 000000000..c8574c2a0 --- /dev/null +++ b/docs/traceability/browser-session-rustc-llvm-plugin-authority.md @@ -0,0 +1,46 @@ +# Browser Session rustc LLVM plugin authority + +## Problem + +OriginWeave's Browser Session production closure already fails closed for repository-selected rustc executables, wrappers, code-generation backends, linker plugins, linker scripts, response files, sysroots, external crates, native libraries, and other reviewed Cargo execution/input surfaces. One rustc-native execution surface was still outside that classifier: `-Z llvm-plugins=`. + +The current rustc option table exposes `llvm_plugins` as an unstable list option described as “a list LLVM plugins to enable (space separated)”. `rustc_codegen_llvm` copies that list into the LLVM module configuration and passes the joined plugin list to `LLVMRustOptimize`. A Git-owned Cargo `rustflags` value can therefore select code that participates in compiler optimization without changing `Cargo.toml` dependency/source topology, the selected code-generation backend, or the final linker. + +This is compiler execution provenance, not an optimization-only preference. A reviewed Browser Session build must not gain a repository-selected LLVM pass plugin through an otherwise innocuous Cargo profile/build/target flag path. + +## Decision + +The existing Browser Session Cargo compiler-authority contract remains the single owner of Git-owned rustc execution/input selection. Its existing rustflag classifier now treats both compact and split LLVM-plugin forms as code-generation execution extensions: + +- `-Zllvm-plugins=tools/review-bypass-pass.so` +- `-Z llvm-plugins=tools/review-bypass-pass.so` + +The classifier is already consumed by build-level `rustflags`, target-level `rustflags`, root-manifest profile `rustflags`, and repository Cargo-config profile `rustflags`. No second Cargo topology scanner or Browser Session source-discovery implementation is introduced. + +Ordinary flags that do not replace or dynamically extend compiler execution remain outside this prohibition. The focused contract keeps `-C opt-level=2` and a reviewed `--cfg` as controls. + +## RED → repair evidence + +- RED: `e87cab84490aea41a4bb5f7de20485cbd770642d` adds hostile build, target, Cargo-config profile, and root-profile LLVM-plugin fixtures that the predecessor classifier did not reject. +- Repair: `60f27dfde50b7134ebd43d9d0574e7edc169191d` extends the existing rustc code-generation execution classifier to reject `llvm-plugins=` in compact or split `-Z` form. Existing build/target/profile call sites inherit the repair. +- Exact executable GREEN is not inferred from these source changes. The PR remains Draft and must earn fresh hosted repository/security evidence after the canonical parent lineage is reconciled. + +## Security effect + +A Git-owned Cargo config or profile can no longer introduce an LLVM pass plugin while leaving the reviewed package/source graph unchanged. This closes a compiler-process extension path adjacent to, but distinct from, the already governed rustc code-generation backend and linker-plugin-LTO boundaries. + +The plugin path is treated as execution provenance only. Browser Session does not learn LLVM plugin semantics, and no LLVM implementation detail becomes Browser Session domain truth. + +## Residual authority + +This repository-source contract does not claim control over ambient execution surfaces supplied outside the reviewed tree, including environment/direct-CLI rustflags, ancestor or `$CARGO_HOME` Cargo config, runner images, the installed rustc/LLVM distribution itself, or administrator-provided toolchain mutation. Those require CI/release supply-chain evidence, immutable toolchain identity, SBOM/provenance, and reproducibility controls rather than another leaf-source scanner. + +A future approved LLVM plugin would require an explicit versioned artifact contract, immutable digest/provenance, toolchain/LLVM ABI compatibility evidence, security review, exact-head tests, SBOM/attestation, and rollback before this fail-closed rule is relaxed. + +## Primary references + +Rust Project. (2026). *rustc_session::options::UnstableOptions* (`llvm_plugins`). Rust nightly compiler documentation. https://doc.rust-lang.org/nightly/nightly-rustc/rustc_session/options/struct.UnstableOptions.html + +Rust Project. (2026). *rustc_session options source* (`llvm_plugins`: “a list LLVM plugins to enable”). Rust compiler source documentation. https://doc.rust-lang.org/beta/nightly-rustc/src/rustc_session/options.rs.html + +Rust Project. (2026). *rustc_codegen_llvm::back::write*. Rust compiler source. The LLVM plugin list is propagated into `LLVMRustOptimize`. https://github.com/rust-lang/rust/blob/main/compiler/rustc_codegen_llvm/src/back/write.rs diff --git a/docs/traceability/browser-session-rustdoc-doc-meta-input-authority.md b/docs/traceability/browser-session-rustdoc-doc-meta-input-authority.md new file mode 100644 index 000000000..bc79963eb --- /dev/null +++ b/docs/traceability/browser-session-rustdoc-doc-meta-input-authority.md @@ -0,0 +1,41 @@ +# Browser Session rustdoc documentation-metadata input authority + +Status: source-semantic repair evidence; not hosted executable GREEN. + +## Problem + +Nightly rustdoc exposes `--write-doc-meta-dir` and `--read-doc-meta-dir` behind `-Z unstable-options`. The write option emits a crate's shared documentation metadata to a directory. The read option is different: rustdoc enters finalize mode without crate source and merges cross-crate state from one or more supplied metadata directories into the documentation output. + +Repository-owned Cargo `rustdocflags` could therefore select an external `--read-doc-meta-dir` and change generated cross-crate documentation/search state without changing the reviewed Rust source, Cargo package topology, or compiler/linker inputs. Treating the source tree as the complete documentation provenance would be false. + +## Boundary and alternatives + +`tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for repository-selected rustc/rustdoc execution and external input authority. No second Cargo scanner or metadata parser is introduced. + +Path allowlisting was rejected because a directory spelling does not prove immutable contents, ownership, symlink containment, release identity, or compatibility with the rustdoc/toolchain that generated the metadata. Blocking both read and write metadata directories was also rejected: `--write-doc-meta-dir` selects an output destination rather than an external documentation input. + +## RED → repair + +RED `b9103bd862a295954f7fc809e0d732fa982713f4` adds `tests/test_browser_session_rustdoc_doc_meta_input_authority_contract.py` with: + +- build-level split `--read-doc-meta-dir PATH`, +- target-level equals `--read-doc-meta-dir=PATH`, and +- `--write-doc-meta-dir PATH` as an allowed output-only control. + +The prior authority classifier accepted both hostile read cases. + +Repair `47ff4370afdda5487224c437f6883d8947500c3f` broadens the existing rustdoc documentation-input classifier rather than creating another topology scan. `RUSTDOC_DOCUMENTATION_INPUT_OPTIONS` now includes `--read-doc-meta-dir` alongside the existing rendered-file inputs. Build- and target-level `rustdocflags` use the same `_flags_select_rustdoc_documentation_input()` call site and report `rustdocflags:documentation input`. + +`--write-doc-meta-dir` remains allowed because it is an output destination. That distinction is a contract invariant, not a naming convenience. + +## Primary reference + +Rust Project. (2026). *The rustdoc book: Unstable features*. https://doc.rust-lang.org/nightly/rustdoc/unstable-features.html + +The current rustdoc book states that `--read-doc-meta-dir` runs rustdoc in finalize mode, accepts multiple metadata directories, and is used to merge cross-crate state; no crate source is supplied in that mode. It separately states that `--write-doc-meta-dir` writes shared metadata to a directory. + +## Buyer/security effect + +A repository review can no longer silently acquire cross-crate documentation metadata through Git-owned build/target `rustdocflags` while claiming that generated documentation is derived only from the reviewed source/dependency closure. An eventual approved metadata import requires immutable directory/artifact identity, producer toolchain identity, crate/source provenance, content digests, compatibility evidence, SBOM/provenance linkage, and rollback/expiry rules. + +This source contract does not prove a generated-docs publication, GitHub Pages deployment, browser rendering, accessibility, CSP, or immutable release. Environment/direct-CLI `RUSTDOCFLAGS`, `CARGO_ENCODED_RUSTDOCFLAGS`, ancestor/`$CARGO_HOME` config, external metadata producers, and runner/toolchain state remain CI/release provenance surfaces. diff --git a/docs/traceability/browser-session-rustdoc-doctest-build-arg-authority.md b/docs/traceability/browser-session-rustdoc-doctest-build-arg-authority.md new file mode 100644 index 000000000..459db7e66 --- /dev/null +++ b/docs/traceability/browser-session-rustdoc-doctest-build-arg-authority.md @@ -0,0 +1,52 @@ +# Browser Session rustdoc doctest compiler-argument authority + +Status: Draft; source-semantic contract current on PR #317. Hosted executable evidence is still required before this generation is GREEN. + +## Problem + +Cargo can pass repository-owned `[build].rustdocflags` and matching `target..rustdocflags` directly to `rustdoc`. Nightly rustdoc's `--doctest-build-arg` then forwards one argument per occurrence to the compiler used to build documentation tests. This is a second-order compiler-input boundary: a reviewed rustdoc invocation can otherwise smuggle `--sysroot`, `--extern`, response files, code-generation backends/plugins, linker selection, linker scripts, native inputs, or other already-governed rustc/linker authority into doctest compilation. + +The Browser Session provenance contract therefore has to inspect the forwarded compiler argument vector, not merely the outer rustdoc command line. + +## Ownership and constraints + +`tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source/dependency topology. `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single owner for Git-owned Cargo-selected compiler/linker execution and external-input authority. This repair reuses its existing rustc/codegen/linker classifiers and does not add a second Cargo topology scanner. + +The rule does not blanket-ban `--doctest-build-arg`. Forwarded arguments that do not widen executable or external-input authority, such as a reviewed `--cfg` or `-Copt-level=2`, remain allowed. + +## RED → repair + +RED `eeb6944e9f77f80dfc0aa5812dcce42b02b8f7b4` adds hostile build/target fixtures for: + +- forwarded `--sysroot=...`, +- split forwarded `-C` + `linker=...`, and +- forwarded `-Zcodegen-backend=...`. + +It also retains `--cfg=originweave_reviewed` and `-Copt-level=2` as non-authority controls. + +Repair `35733ea613225277f4baa7e100d4403453810e2f` adds `_flags_select_rustdoc_doctest_compiler_authority()` to the canonical Cargo compiler-authority contract. The helper reconstructs the ordered compiler arguments carried by split and `--doctest-build-arg=...` forms, fails closed on a missing operand, and reuses the existing codegen, linker, and external-input classifiers. Both build-level and target-level `rustdocflags` consume the same helper. + +## Primary evidence + +- The Cargo Book, *Configuration*, documents `build.rustdocflags` and `target..rustdocflags` as low-level custom flags passed to rustdoc and separately identifies environment/direct-command sources with higher precedence. +- The nightly rustdoc book, *Unstable features*, documents `--doctest-build-arg` as a way to add arguments to rustc when compiling doctests; the unstable command-line family requires nightly rustdoc with `-Z unstable-options`. + +## Security effect + +Git-owned Cargo configuration can no longer use rustdoc's doctest compiler forwarding as an unchecked tunnel around the Browser Session compiler/linker provenance policy. The inner compiler vector is evaluated with the same authority semantics as direct Cargo rustflags rather than with a duplicate policy. + +## Residual execution provenance + +This repository-source contract intentionally does not claim authority over: + +- `RUSTDOCFLAGS`, `CARGO_ENCODED_RUSTDOCFLAGS`, `CARGO_BUILD_RUSTDOCFLAGS`, or target-specific environment overrides; +- direct `cargo rustdoc -- ...` or manual rustdoc invocation; +- runner image, PATH, rustup/toolchain, default rustdoc/rustc identity, or externally supplied compiler/linker artifacts; +- future rustdoc/rustc options that introduce a new execution/input grammar not yet represented by the canonical classifiers; +- immutable artifact identity, SBOM/attestation, sandbox policy, compatibility qualification, and rollback for any future approved external compiler component. + +Those surfaces require CI/release environment evidence from their canonical owners. A newly introduced rustdoc/rustc forwarding primitive is a fresh provenance finding until it is mapped to the canonical classifier and covered by hostile and control fixtures. + +## Acceptance + +This generation is source-semantic only until the exact reconciled head has hosted repository/security execution and current-head independent review. A command acknowledgement, static inspection, predecessor workflow result, or skipped Draft workflow is not executable GREEN. diff --git a/docs/traceability/browser-session-rustdoc-doctest-execution-authority.md b/docs/traceability/browser-session-rustdoc-doctest-execution-authority.md new file mode 100644 index 000000000..4aea2c1ef --- /dev/null +++ b/docs/traceability/browser-session-rustdoc-doctest-execution-authority.md @@ -0,0 +1,50 @@ +# Browser Session rustdoc doctest execution authority + +Status: Draft; source-semantic contract current on PR #317. Hosted executable evidence is still required before this generation is GREEN. + +## Problem + +Cargo can pass repository-owned `[build].rustdocflags` and matching `target..rustdocflags` directly to `rustdoc`. Rustdoc can then select external programs that participate in documentation-test execution: + +- `--test-runtool ` executes the specified wrapper instead of the doctest executable. +- nightly `--test-builder ` replaces the default rustc-like program used to compile doctests. +- nightly `--test-builder-wrapper ` wraps the selected test builder and may be repeated. + +Those selectors can change executable provenance without changing the reviewed Cargo package/source graph, compiler package dependencies, or Browser Session domain code. They therefore belong to the existing Cargo-selected execution/input authority boundary rather than to rustdoc semantics owned by Browser Session. + +## Ownership and constraints + +`tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source/dependency topology. `tests/test_browser_session_cargo_compiler_authority_contract.py` owns repository-selected Cargo execution and external-input authority. This repair reuses that owner and does not add a second topology/configuration scanner. + +Browser Session does not own rustdoc, Cargo, the Rust toolchain, or doctest scheduling. It only requires that Git-owned configuration cannot silently replace or wrap programs that compile or execute Browser Session documentation tests. + +## Evidence and repair + +RED `ab2fffacffaa061387440c104d4f2b93b7b9675d` adds hostile build/target `rustdocflags` fixtures for `--test-runtool`, `--test-builder`, and `--test-builder-wrapper`. Ordinary doctest arguments that do not select an external executable, including `--test-args` and `--test-run-directory`, remain controls. + +Repair `d9c55cfecd2710fcdb585f1ba971cef413e631d5` adds `_flags_select_rustdoc_test_execution()` to the canonical Cargo compiler-authority contract and applies it to build-level and target-level `rustdocflags`. The rule is fail-closed for the three executable selectors in split or `--option=value` form; it is not a blanket ban on doctest flags. + +Primary references: + +- Cargo Book, Configuration: `build.rustdocflags` and target `rustdocflags` are custom flags passed to rustdoc; environment/direct-command sources have separate precedence. +- rustdoc book, Command-line arguments: `--test-runtool` executes a chosen wrapper instead of the doctest executable. +- rustdoc book, Unstable features: `--test-builder` selects the rustc-like program used to compile doctests and `--test-builder-wrapper` wraps that program. + +## Security effect + +Repository review now covers the Git-owned Cargo paths that could otherwise select a doctest runner, test compiler, or compiler wrapper while leaving Browser Session source and dependency topology unchanged. Approval of any such executable later must be an explicit provenance decision, not an incidental rustdoc flag. + +## Residual execution provenance + +This source contract intentionally does not claim authority over: + +- `RUSTDOCFLAGS`, `CARGO_ENCODED_RUSTDOCFLAGS`, `CARGO_BUILD_RUSTDOCFLAGS`, or target-specific environment overrides; +- direct `cargo rustdoc -- ...` / manual rustdoc invocation; +- runner-image, PATH, rustup/toolchain, default rustdoc/rustc identity, or externally supplied wrapper binaries; +- immutable artifact identity, SBOM/attestation, sandbox policy, compatibility qualification, and rollback for a future approved doctest execution program. + +Those surfaces require CI/release environment evidence from their canonical owners. A future approved runner/builder/wrapper must be versioned and immutable, tied to the exact toolchain and reviewed policy, and covered by executable tests before this fail-closed rule is relaxed. + +## Acceptance + +This generation is source-semantic only until the exact reconciled head has hosted repository/security execution and current-head independent review. A command acknowledgement, static inspection, or predecessor workflow result is not executable GREEN. diff --git a/docs/traceability/browser-session-rustdoc-external-input-authority.md b/docs/traceability/browser-session-rustdoc-external-input-authority.md new file mode 100644 index 000000000..74110e600 --- /dev/null +++ b/docs/traceability/browser-session-rustdoc-external-input-authority.md @@ -0,0 +1,46 @@ +# Browser Session rustdoc external-input authority + +Status: Draft contract evidence on PR #317. This document does not claim hosted repository/security GREEN. + +## Decision + +Git-owned Cargo `rustdocflags` are subject to the same external crate/native-library input provenance gate as Git-owned `rustflags`. Both build-level and matching target-level rustdoc flags fail closed when they introduce `--extern`, `-L` / `--library-path`, or `-l` input authority outside the reviewed Cargo production topology. + +This is separate from rustdoc executable/linker selection. `build.rustdoc` and rustdoc codegen linker options were already covered; this slice closes the external-input half of the rustdoc boundary. + +## Problem + +Cargo documents `build.rustdocflags` and matching `target..rustdocflags` / `target..rustdocflags` as extra command-line flags passed to rustdoc. `cargo rustdoc` also documents that rustdoc receives `-L` and `--extern` arguments as part of normal dependency wiring. Rustdoc itself exposes `-L PATH` and its long alias `--library-path PATH` to add dependency search paths. Therefore a repository-owned rustdoc flag can widen documentation-time crate/native-library inputs even when the production Cargo manifests and the rustdoc executable remain unchanged. + +The predecessor Browser Session contract classified external inputs for `rustflags` and later applied that classifier to `rustdocflags`, but the classifier recognized only `-L` and not rustdoc's equivalent `--library-path`. A Git-owned `--library-path tools/review-bypass-deps` or `--library-path=tools/review-bypass-deps` could therefore bypass the explicit `rustdocflags:external link input` marker. + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production package/source/dependency topology. +- `tests/test_browser_session_cargo_compiler_authority_contract.py` remains the single writer for Git-owned Cargo compiler/rustdoc execution and input authority. +- Ordinary documentation flags such as `--document-private-items` and `--cfg docsrs` remain allowed. +- Environment `RUSTDOCFLAGS` / `CARGO_ENCODED_RUSTDOCFLAGS` and direct `cargo rustdoc -- ...` CLI injection remain CI/runtime authority surfaces and are not claimed closed here. + +## RED → repair + +The original rustdoc external-input slice was introduced by RED `5928b1a614c8620203675b609b75f5489338e06d` and repair `e8edb487b779a1c4ff22bf2ca4c62ae7e52abd8b`, which applied the shared external-input classifier to build and target `rustdocflags`. + +Follow-up review found that the long rustdoc alias was still outside that classifier: + +- RED `edbd3ee2d1cfca8782c9d22cd4fa556107637adb` adds `tests/test_browser_session_rustdoc_library_path_input_authority_contract.py` with build-level split `--library-path PATH`, target-level `--library-path=PATH`, and an unrelated-rustdocflag control. The hostile fixtures require the policy-specific `rustdocflags:external link input` marker. +- Repair `af11b318a4eb64867cc9eabedcf236b62fbac67f` extends the existing shared external-library-input classifier with `--library-path` split/equal forms. No new Cargo topology/config scanner is introduced. +- Supplemental rustdoc metadata, render-input, doctest-compiler, and doctest-execution fixtures now assert their policy-specific markers instead of accepting only the common `Cargo .*execution override` prefix. + +These commits are source-level contract evidence only until the exact head receives hosted executable repository/security evidence. + +## Residual surfaces + +Environment-selected rustdoc flags, direct `cargo rustdoc` trailing arguments, unmodeled rustdoc arguments with equivalent external-input semantics, sysroot/toolchain composition, and target-specific mechanisms outside the currently modeled Cargo config remain separate review surfaces. + +## Primary references + +The Rust Project Developers. (2026). *Configuration*. *The Cargo Book*. https://doc.rust-lang.org/cargo/reference/config.html + +The Rust Project Developers. (2026). *cargo rustdoc*. *The Cargo Book*. https://doc.rust-lang.org/cargo/commands/cargo-rustdoc.html + +The Rust Project Developers. (2026). *Command-line arguments*. *The rustdoc book*. https://doc.rust-lang.org/rustdoc/command-line-arguments.html diff --git a/docs/traceability/browser-session-rustdoc-render-file-input-authority.md b/docs/traceability/browser-session-rustdoc-render-file-input-authority.md new file mode 100644 index 000000000..c2dfb7ab2 --- /dev/null +++ b/docs/traceability/browser-session-rustdoc-render-file-input-authority.md @@ -0,0 +1,80 @@ +# Browser Session rustdoc render-file input authority + +Status: source-semantic repair evidence; not hosted executable GREEN. + +## Problem + +OriginWeave treats `tests/test_browser_session_cargo_compiler_authority_contract.py` as the single writer for repository-selected Cargo compiler/rustdoc execution and input authority. The existing contract rejected rustdoc replacement, `@path`, `--extern`, `-L`/`-l`, sysroot, codegen/linker authority, doctest execution programs, and doctest compiler forwarding, but it did not initially classify rustdoc's rendering file selectors. + +Rustdoc documents `--html-in-header`, `--html-before-content`, and `--html-after-content` as reading files and inserting their contents into generated HTML. It also reads file inputs for `--extend-css`/`-e`, `--theme`, and `--check-theme`; current rustdoc source additionally exposes `--markdown-before-content` and `--markdown-after-content` as file-backed rendering inputs. The unstable `--index-page PATH` option is another file-backed surface: rustdoc converts the argument to a path, requires it to be a file, records it as a loaded path, and uses that Markdown file as the generated index page. The unstable `--with-examples INPUT.calls` option is also an input authority: rustdoc documents that the calls file produced by the scrape-examples phase is passed into a later documentation invocation through `--with-examples`. A Git-owned `build.rustdocflags`, target `rustdocflags`, or nightly host `rustdocflags` entry could therefore make generated documentation depend on content outside the reviewed Cargo source/dependency closure even when the Rust source and compiler inputs were unchanged. + +For a repository that publishes generated documentation, that is a provenance and documentation-integrity gap. It is not treated as Browser Session runtime policy authority, and no claim is made that every such input is executable script content. + +## Constraints + +- Production Cargo package/source topology remains owned by `tests/test_browser_session_trusted_adapter_boundary.py`. +- This contract must not create a second Cargo configuration/topology scanner. +- Ordinary rustdoc presentation controls that do not make rustdoc read another file, such as `--document-private-items`, `--default-theme`, and `--markdown-css`, remain outside this fail-closed rule. +- Output-only scrape-example selection such as `--scrape-examples-output-path` is not reclassified as an input merely because it names a path. +- Environment `RUSTDOCFLAGS` / `CARGO_ENCODED_RUSTDOCFLAGS`, ancestor or `$CARGO_HOME` configuration, and direct `cargo rustdoc -- ...` remain CI/release environment provenance surfaces. + +## Alternatives considered + +1. **Allow arbitrary render files when their path is repository-relative.** Rejected. A path spelling does not establish immutable identity, reviewed ownership, symlink containment, or release provenance. +2. **Copy rustdoc option parsing into a new supplemental Cargo scanner.** Rejected because it would violate the existing compiler-authority single-writer boundary. +3. **Fail closed on the file-selecting rustdoc options in the existing owner.** Selected. It is small, deterministic, and preserves the current authority topology. + +## Decision + +Initial RED commit `2f8233cb7957ffd959d88ed8b3aca44bf3f6f001` added a realistic repository Cargo fixture in `tests/test_browser_session_rustdoc_render_input_authority_contract.py`. Before the repair, `build.rustdocflags = ["--html-in-header", "tools/review-bypass-header.html"]` and equivalent target/render-file selectors were not rejected by the canonical authority helper. + +Initial repair commit `ab259680e9bc8c6fde2221cd4c12d2a36721e93a` added a rustdoc file-input classifier to the existing compiler-authority owner and applied it to both build-level and target-level `rustdocflags`. Coverage commit `a1d8a7fe27ed67f2189dd19f276cbc960632441c` exercised the modeled stable/unstable HTML, Markdown, CSS and theme selectors plus safe controls. + +A fresh primary-source sweep then found the unstable `--index-page PATH` file input that the first classifier generation had not modeled. Follow-up RED `a17cb3d60e5a09b7e10131dcef9eec39bded3d97` added a Cargo fixture using `-Z unstable-options --index-page tools/review-bypass-index.md`; the prior classifier accepted it. Repair `54041d692aafc9d2c9d55134db9df4810c5b76d0` added `--index-page` to the same canonical selector set. Coverage `5dda25c3d4892d1bb813f86dd9d0d6873a19a10a` added the equals-form target configuration so split and equals spellings are both constrained. + +A later documentation-metadata finding broadened the owner name, not the render-file semantics. Repair `47ff4370afdda5487224c437f6883d8947500c3f` renamed the shared classifier to `_flags_select_rustdoc_documentation_input()` and its option set to `RUSTDOC_DOCUMENTATION_INPUT_OPTIONS`, with rejection marker `rustdocflags:documentation input`, so rendered-file and cross-crate metadata inputs share one accurate authority boundary. The separate metadata rationale and RED are documented in `browser-session-rustdoc-doc-meta-input-authority.md`. + +A 2026-09-20 primary-source sweep found a second unmodeled unstable documentation input: `--with-examples INPUT.calls`. Rustdoc's own book describes a two-phase workflow in which `--scrape-examples-output-path output.calls` writes a calls file and a later `rustdoc ... --with-examples output.calls` invocation consumes that file. Structural RED `d52950ebcc9943d2b9f2e554e08ce518382888e5` added build-level split-form and target-level equals-form Cargo fixtures and kept `--scrape-examples-output-path` as an explicit output-only control. Minimal repair `363a6399765e0ccd7a022a0526a6c77679b1c5f5` added only `--with-examples` to the existing `RUSTDOC_DOCUMENTATION_INPUT_OPTIONS` owner. Coverage successor `dad69ee929e7823140c474e5bc25656f0ef69385` adds the nightly `[host] rustdocflags` hostile case so build, target, and host ownership paths are directly regression-bound without adding another Cargo scanner. + +The classifier now includes these rendered/documentation-input selectors: + +- `--html-in-header` +- `--html-before-content` +- `--html-after-content` +- `--markdown-before-content` +- `--markdown-after-content` +- unstable `--index-page` +- `--extend-css` and its short `-e` form +- `--theme` +- `--check-theme` +- unstable `--with-examples` + +The trusted-adapter boundary remains the single writer for Cargo package/source discovery; this change only extends the existing rustdoc input-authority classifier. + +## Primary references + +- Rust Project. (2026). *The rustdoc book: Command-line arguments*. https://doc.rust-lang.org/rustdoc/command-line-arguments.html + - documents file-backed HTML inclusion, CSS extension, theme/check-theme, and the distinction between `--markdown-css` and files whose contents rustdoc reads. +- Rust Project. (2026). *The rustdoc book: Unstable features*. https://doc.rust-lang.org/nightly/rustdoc/unstable-features.html + - documents `--with-examples INPUT.calls` and states that the generated calls file from the scrape-examples phase is passed to the subsequent documentation invocation; this is the primary authority for classifying `--with-examples` as an input rather than an output selector. +- Rust Project. (2026). *rustdoc option definitions (`rustdoc/lib.rs`)*. https://doc.rust-lang.org/beta/nightly-rustc/src/rustdoc/lib.rs.html + - identifies the HTML/Markdown file selectors, `--extend-css`, and unstable `--index-page PATH` used by current rustdoc. +- Rust Project. (2026). *rustdoc configuration (`rustdoc/config.rs`)*. https://doc.rust-lang.org/beta/nightly-rustc/src/rustdoc/config.rs.html + - shows `ExternalHtml::load` receiving the HTML/Markdown file option values and separately shows `--index-page` becoming a `PathBuf`, being required to resolve to a file, and being recorded as a loaded path. +- Rust Project. (2026). *The Cargo Book: Configuration*. https://doc.rust-lang.org/cargo/reference/config.html + - documents `build.rustdocflags` as custom flags passed to rustdoc and Cargo's hierarchical configuration model. + +## Security and buyer effect + +Repository-reviewed Rust source can no longer silently acquire additional rendered-document content through Git-owned Cargo `rustdocflags` using the modeled rustdoc file selectors, including the unstable custom index page and scrape-examples calls file. This narrows the documentation supply-chain boundary and prevents a source review from incorrectly implying that generated documentation is derived only from reviewed repository inputs. + +This does **not** prove generated documentation publication, GitHub Pages deployment, CSP behavior, browser rendering, accessibility, or release provenance. Those require their own exact-head build/publish/browser evidence. + +## Residual risk and follow-up + +- Environment/direct-CLI rustdoc flags and ambient Cargo configuration remain CI/release supply-chain inputs. +- `--markdown-css` writes a stylesheet reference into Markdown-rendered HTML rather than loading the referenced file contents during rustdoc execution. It is intentionally not classified as this file-input surface; external-resource policy for published documentation should be owned by the docs/site publication boundary. +- `--scrape-examples-output-path` writes the calls artifact and is intentionally kept as an output-only control; if a future rustdoc revision changes that contract, toolchain qualification must revisit the classification. +- Rustdoc's unstable `--read-doc-meta-dir` is now classified by the same canonical documentation-input owner, but its directory/merge semantics and output-only `--write-doc-meta-dir` control are documented separately. +- Future rustdoc releases may add file-backed rendering options. Exact toolchain qualification must update this contract when those options become relevant. +- An eventual approved custom render asset contract must identify the artifact immutably, prove repository/release provenance and containment, and connect the generated documentation to SBOM/provenance and rollback evidence rather than relying on a pathname allowlist. diff --git a/docs/traceability/browser-session-sysroot-input-authority.md b/docs/traceability/browser-session-sysroot-input-authority.md new file mode 100644 index 000000000..87906ae65 --- /dev/null +++ b/docs/traceability/browser-session-sysroot-input-authority.md @@ -0,0 +1,44 @@ +# Browser Session sysroot input authority traceability + +Status: Draft contract evidence on PR #317. This document does not claim hosted repository/security GREEN. + +## Problem + +The Browser Session Cargo authority contract already fails closed on repository-owned compiler/linker executable replacement and modeled external crate/native-library inputs. A separate Rust compiler input surface remained: Git-owned Cargo `rustflags` or `rustdocflags` could pass `--sysroot ` or `--sysroot=` and select a different Rust sysroot while the reviewed Cargo package/source closure remained unchanged. + +`rustc --sysroot` overrides the system root used to find crates distributed with Rust. `rustdoc --sysroot` likewise changes the sysroot used while compiling documentation. A repository-selected sysroot is therefore compiler/documentation input authority, not ordinary diagnostic or optimization configuration. + +## Constraints + +- `tests/test_browser_session_trusted_adapter_boundary.py` remains the single writer for production Cargo package/source topology. This supplemental contract consumes that authority and does not duplicate Cargo topology discovery. +- A repository-selected sysroot remains fail-closed until a separate reviewed contract establishes immutable toolchain/sysroot identity, artifact provenance, SBOM/reproducibility expectations, and release/runtime qualification. +- This contract covers Git-owned `.cargo/config.toml` and `.cargo/config` `rustflags`/`rustdocflags`. Environment `RUSTFLAGS`, `CARGO_ENCODED_RUSTFLAGS`, `RUSTDOCFLAGS`, `CARGO_ENCODED_RUSTDOCFLAGS`, direct compiler/documentation CLI arguments, runner images, rustup/toolchain installation, and ambient sysroot composition remain CI/runtime-owner surfaces. +- Unrelated Rust flags are not rejected merely because they occur in `rustflags` or `rustdocflags`. + +## RED + +Commit `3943f268395180d199992709393190510ae48b2d` adds `tests/test_browser_session_sysroot_input_contract.py`. The hostile fixtures cover: + +- build-level split `rustflags = ["--sysroot", "tools/review-bypass-sysroot"]`; +- build-level equals-form `rustflags = ["--sysroot=tools/review-bypass-sysroot"]`; +- target-scoped split `rustflags`; +- build-level equals-form `rustdocflags`; +- target-scoped split `rustdocflags`. + +The predecessor classifier handled `--extern`, `-L`, and `-l` but did not classify `--sysroot`, so these fixtures preserve the missing compiler/documentation-input authority as a source-semantic RED. The control fixture keeps unrelated `--remap-path-prefix` configuration allowed. + +## Decision and repair + +Commit `f20401f0368ac9ab5f9756fc285972791526887c` extends the existing `_rustc_argument_extends_external_inputs()` classifier rather than adding a new Cargo scanner. Split and equals `--sysroot` spellings now classify as external compiler/documentation input authority. Because build/target `rustflags` and `rustdocflags` already consume `_flags_extend_external_link_inputs()`, all four Git-owned Cargo configuration paths fail closed through the same reviewed authority boundary. + +The repair intentionally does not inspect or allowlist sysroot contents. A path allowlist would not establish that the standard-library crates, compiler-private crates, metadata, native objects, or supporting toolchain artifacts are the immutable reviewed artifacts expected by a release. + +## Security effect and residual risk + +The repair closes the modeled Git-owned Cargo path that could replace rustc/rustdoc sysroot inputs without changing the reviewed Browser Session Cargo package/source closure. It does not prove environment- or direct-CLI-selected sysroots, runner-image contents, rustup/toolchain installation state, or the integrity/reproducibility of the default sysroot. Those remain CI/release/toolchain provenance concerns and must not be inferred from this source contract. + +## Primary references + +The Rust Project. (n.d.). *Command-line arguments: `--sysroot`: override the system root*. The rustc book. Retrieved September 18, 2026, from https://doc.rust-lang.org/rustc/command-line-arguments.html#--sysroot-override-the-system-root + +The Rust Project. (n.d.). *Command-line arguments: `--sysroot`: override the system root*. The rustdoc book. Retrieved September 18, 2026, from https://doc.rust-lang.org/rustdoc/command-line-arguments.html#--sysroot-override-the-system-root diff --git a/docs/traceability/browser-session-trusted-adapter-boundary.md b/docs/traceability/browser-session-trusted-adapter-boundary.md new file mode 100644 index 000000000..2945d7788 --- /dev/null +++ b/docs/traceability/browser-session-trusted-adapter-boundary.md @@ -0,0 +1,87 @@ +# Browser Session trusted-adapter boundary + +- **Status:** active-PR security and composition evidence for PR #317; not protected-main behavior +- **Owner:** OriginWeave Browser Session bounded context +- **Related authority:** `docs/THREAT_MODEL.md`, `SECURITY.md`, ADR 0114, issue #312 + +## Problem + +`DisposableContextPort` is a cross-crate service-provider interface. Its implementation is allowed to return the browser-issued isolation and browsing-context address that Browser Session records before minting `PresentationMutationAuthority`. The Rust type system cannot distinguish a reviewed implementation from malicious code merely because both implement the same public trait. Aggregate-issued request/completion correlation, incarnation binding, duplicate rejection, exact-fact recovery, and monotonic epochs prevent replay, swapping and ABA classes; **request/completion correlation is not adapter authentication**. + +Treating an arbitrary in-process implementation as if it were an untrusted web actor would therefore create a false security promise. A nonce or opaque request handed to that implementation can simply be echoed. It does not prove that Chromium created a disposable user-context boundary. + +## Trust boundary + +OriginWeave's threat model places the Rust control plane and privileged Chromium/browser adapters inside the trusted computing base. `originweave-browser-session` is an internal `publish = false` crate, not an extension SDK that promises isolation from hostile linked Rust code. A malicious crate already executing inside this trusted process is a supply-chain compromise / trusted-code compromise; it is not made safe by making one handle constructor opaque. + +This does **not** make every implementation acceptable. Product composition may bind only a reviewed privileged lifecycle adapter; a caller-selected production adapter is not admitted. `DisposableContextPort` is an internal TCB SPI, not caller-selected product policy. Any production source that references this SPI outside the Browser Session owner and any production crate that depends on `originweave-browser-session` is a repository review surface and must be explicitly allowlisted by contract. An allowlist entry is evidence about a production surface that exists on the same exact branch, not permission reserved for a future implementation. Test doubles remain allowed only under test code and grant no shipped product capability. + +No external production consumer is approved on the current #317 tree. The intended future consumer is the versioned WebDriver BiDi lifecycle adapter in `crates/originweave-bidi`, but the current BiDi manifest does not depend on Browser Session and `crates/originweave-bidi/src/lifecycle_acl.rs` does not exist. When #316 or a verified successor introduces that adapter, the implementation, manifest dependency, and exact allowlist entries must arrive in the same reviewed delta. Browser Session does not pre-authorize those future paths. + +## Enforced repository contract + +`tests/test_browser_session_trusted_adapter_boundary.py`, `tests/test_browser_session_implicit_workspace_member_contract.py`, and `tests/test_browser_session_custom_target_source_contract.py` enforce the currently supportable boundary: + +1. the Browser Session crate remains `publish = false`; +2. the canonical threat model continues to classify privileged browser integration as trusted Zone C code; +3. production references to `DisposableContextPort` outside the Browser Session owner are limited to the explicit reviewed source allowlist; +4. production crate dependencies on `originweave-browser-session` are limited to the explicit reviewed manifest allowlist, including direct package aliases, table syntax, target-specific production dependencies, and workspace-inherited aliases resolved through root `[workspace.dependencies]`; +5. both allowlists exactly describe production surfaces that exist on the current tree, so an absent future source path or dependency cannot be pre-approved; +6. no current production source outside the Browser Session owner references `bind_lifecycle_port` as a caller-selected composition escape hatch, regardless of method-call, UFCS, or whitespace spelling; +7. the primary source and manifest review surface is derived from the Cargo workspace's explicit `[workspace].members` plus the workspace-root package when `[package]` is present, rather than from a `crates/*` directory convention. Workspace-member globs fail closed until this contract is explicitly extended and reviewed; +8. recursive in-repository production `path` dependencies are also reviewed, because Cargo automatically makes path dependencies residing in the workspace directory workspace members even when they are omitted from the explicit `members` list. An implicit local package therefore cannot hide a Browser Session dependency or lifecycle-SPI reference behind another member's `path = ...` edge; and +9. production source review includes explicitly configured `[lib].path` and `[[bin]].path` targets as well as the ordinary `src/**/*.rs` surface. Custom production target paths must remain inside the repository review root and identify existing files, so an already approved Browser Session-dependent crate cannot hide a new lifecycle-SPI source outside `src/`. + +The sixth rule intentionally leaves the product composition owner unclaimed until a reviewed runtime/composition lane exists. When that owner is introduced, its exact path must be added deliberately with architecture and security review rather than discovered implicitly through a new call site. The seventh through ninth rules prevent explicit members outside `crates/*`, a future workspace-root package, automatically enrolled in-workspace path dependencies, and custom Cargo production-target paths from widening the trusted composition surface without entering the same repository review boundary. + +### Scanner false-negative repair + +The first repository contract recognized only the literal unqualified Rust form `impl DisposableContextPort for ...` and the exact method spelling `.bind_lifecycle_port(`. Those are style conventions, not security boundaries: valid Rust can name the trait through a qualified path or alias and can invoke the binding function through UFCS or with different whitespace. + +Test-first commit `100c00487488bbc281106ddf6fe4ae1b60feb16b` adds hostile qualified-trait, aliased-trait, UFCS, and whitespace spellings and exposes those false negatives. Minimal contract repair `7b2334b1df92d03629b7931ce71cd58134b93f4c` makes direct source review spelling-resilient: any external production source containing the SPI token is reviewed, and any production source outside the owner containing the binding API token is rejected until an explicit composition owner is approved. + +A second review found the remaining cross-file alias case: one reviewed module could import or re-export the trait under another name while a different module implements only that alias and therefore contains no `DisposableContextPort` token. Test-first commit `6ce9c1f3b13fe157cfae822a0958c2b8dc2dabd8` records that direct source scanning cannot prove this case. Commit `cb9fb54e4a799919a425f3636cb0a5f1daacfb24` adds the compensating crate-boundary invariant: every production `Cargo.toml` that can link Browser Session must itself be reviewed and allowlisted. A cross-file alias therefore cannot create a new production adapter from an unreviewed crate without first widening an explicit dependency review surface. + +Cargo permits the dependency key itself to be renamed with `package = "originweave-browser-session"` and also permits table-style dependency declarations. Test-first commit `1e46b302254596397a6c4b0bb9ced1be02a33ea1` adds both forms and exposes the narrower manifest-key matcher. Commit `b24b9beb7a0804b90339a0e9e6abce3dd701fc4b` makes the manifest review fail closed on the canonical package token wherever it appears in a production crate manifest, covering direct keys, package aliases, and table syntax. + +A third review found a governance hole in the allowlist itself. The contract pre-listed the future BiDi source path and manifest even though neither current production surface existed. That meant a later change could introduce exactly those surfaces without modifying the security contract, turning a supposedly explicit review surface into latent permission. Test-first commit `6727474a15e85f66917821169cafcba89dbcfbdb` requires both allowlists to equal the surfaces actually discovered on the current tree and therefore fails on those future reservations. Commit `6f2265e8d99cccd7bef89b2aaa4581854f093087` removes the reservations. A future BiDi adapter must now widen the allowlist in the same reviewed change that introduces its source and dependency. + +A fourth review found that Cargo workspace inheritance could bypass the manifest scanner without ever spelling the canonical package name in the consuming crate. A root declaration such as `browser_session = { package = "originweave-browser-session", ... }` under `[workspace.dependencies]` can be consumed by a member as `browser_session = { workspace = true }`; the previous per-member regex saw only the alias. Test-first commit `97b925c0d7c957f99e9b798f45decac70877cd40` records that escape. Commit `aeea79c5d57a1cb1c7c5d3f760a2d728214d8a09` parses Cargo TOML, resolves workspace-inherited dependency aliases to their canonical package, and applies the same review surface to target-specific production dependencies. Dev-only dependencies remain outside the shipped adapter-composition surface. + +A fifth review found that the hardened scanner still discovered production manifests and Rust sources with `crates/*` filesystem globs instead of Cargo's authoritative workspace membership. Cargo permits explicit workspace members at arbitrary relative paths, so a later `plugins/browser-adapter` member could link Browser Session and reference the lifecycle SPI while remaining invisible to the fixed directory glob. Structural RED `93635d092cfcaf613e88770da003b45b6018fa23` adds a hostile workspace member outside `crates/*` and demonstrates that escape. Minimal repair `9aca127b8ae18a6af38353c4023a6c5361c75e85` parses root `[workspace].members`, verifies each explicit member manifest exists, derives production Rust scanning from those members, and fails closed on workspace-member glob syntax until the contract is deliberately extended. The current repository already uses an explicit workspace-member list, so this widens review coverage without changing production Rust or the trust classification. + +A sixth review checked Cargo's workspace-root package rule rather than assuming every package must appear in `[workspace].members`. If the workspace root later gains a `[package]` table, that root package is part of the workspace even when `members = []`; the fifth-generation helper would have ignored the root manifest and `src/**/*.rs`, allowing a root package to link Browser Session or reference/bind its lifecycle SPI without entering the review surface. Structural RED `89f1ce04a7ba4dfbb8157a849e419f842cb1a18c` adds that hostile root-package fixture. Minimal repair `36f13665553323f70f44f25a6bdf52a0b4b178ac` includes the root manifest whenever `[package]` is present while preserving explicit-member validation and the member-glob fail-closed rule. The repository is currently a virtual workspace, so this is prospective fail-closed coverage rather than a production topology change. + +A seventh review checked the remaining Cargo membership rule against the current Cargo Book: **all path dependencies residing inside the workspace directory automatically become workspace members**, even when their package paths are absent from `[workspace].members`. The sixth-generation helper still treated the explicit list plus root package as exhaustive, so `app -> path ../plugins/browser-adapter -> originweave-browser-session` could place a production adapter inside the Cargo workspace while its manifest and source escaped the review scan. Structural RED `b0931ae8b71994ad96689b7e109ff09cca21bd72` adds that hostile implicit-member fixture. Repair `a7798d229631b0f0ab2a8b1132fb08f250df8ecd` follows production dependency sections recursively, resolves direct and workspace-inherited local `path` dependencies relative to their Cargo-defined bases, rejects paths outside the repository review root, and applies the existing source/dependency allowlists to the resulting production-package closure. This changes no Rust runtime behavior; it aligns the security review surface with Cargo's automatic local path-membership semantics. + +An eighth review checked Cargo target topology rather than assuming every shipped Rust source lives under `src/`. The Cargo Book permits `[lib].path` and `[[bin]].path` to name source files elsewhere relative to `Cargo.toml`. After a crate becomes an approved Browser Session dependency, a later custom target outside `src/` could therefore add another lifecycle-SPI source while the manifest remains approved and the file-level allowlist never sees the new file. Structural RED `40c9fb3b452d50fdb1b688e5e7634a1b3858c5e4` adds hostile custom library and binary targets outside `src/`. Minimal repair `b66bb5cd05999f569460c76e173bcf1fd44a2499` extends the production-source closure with configured library and binary target paths, resolves them relative to the package manifest, and fails closed when they escape the repository review root or name a missing file. Detailed standard and decision evidence is recorded in `docs/traceability/browser-session-cargo-target-source-coverage.md`. + +These repairs change no Rust production behavior or trust classification; they make the existing single-writer/TCB policy enforceable across ordinary Rust spelling, Cargo aliasing, workspace inheritance, module-layout choices, explicit workspace-member placement, workspace-root package placement, automatic local path-dependency membership, custom production-target source placement, and future composition changes. + +## Authority invariant + +A `DisposableContextHandle` remains lifecycle addressability, not standalone authority. Browser Session alone owns `PresentationMutationAuthority` issuance and validates session incarnation, isolation identity, browsing-context identity, monotonic epoch and lifecycle state before later adapter I/O. `BrowserSessionIncarnation` is part of the authority binding and provides sequential-ABA protection in authorization validation; the isolation identity does not carry that responsibility by itself. + +A reviewed adapter must create a fresh disposable browser boundary, keep remote addressability scoped to the same Browser Session incarnation, settle creation only for the exact aggregate-issued attempt, and prove exact-boundary destruction. Command acknowledgement alone is not creation, ownership, destruction or browser-observed post-condition evidence. + +## Rejected fixes + +- **Caller-visible nonce or opaque request as adapter authentication:** rejected because the implementation receives the value and can echo it without performing browser I/O. +- **Generic public `TrustedPort` marker trait:** rejected because arbitrary Rust code can implement an unsealed marker and the name creates no security property. +- **Sealing `DisposableContextPort` inside `originweave-browser-session`:** not adopted because the canonical versioned browser adapter lives in a separate crate; Rust has no friend-crate visibility, so sealing here would either break the adapter boundary or force protocol code into the Browser Session owner. +- **Hard-coding `crates/*` as the production composition boundary:** rejected because Cargo workspace/package membership, not directory placement, determines which production crates are built together. +- **Treating explicit `[workspace].members` as exhaustive:** rejected because Cargo automatically enrolls in-workspace path dependencies; repository review must follow those production path edges as well. +- **Treating `src/**/*.rs` as exhaustive production source coverage:** rejected because Cargo may locate library and binary targets at manifest-declared custom paths outside `src/`. +- **Moving deterministic browser policy into WebDriver BiDi/MCP:** rejected; adapters translate qualified browser state and never become policy authority. +- **`--no-sandbox` or browser-process weakening:** unrelated and forbidden. + +## Remaining acceptance + +This dossier resolves the threat-model ambiguity; it does not by itself make #317 merge-ready. Before the Browser Session stack advances: + +- the active branch must inherit every still-valid #229 delta by ordinary non-force adoption; +- `ARCHITECTURE.md` must explicitly include `BrowserSessionIncarnation` in `PresentationMutationAuthority` binding and sequential-ABA responsibility; +- any reviewed production composition path and versioned BiDi adapter must introduce its source, crate dependency, target topology, and allowlist widening together on the exact reviewed tree rather than relying on a reserved future entry; +- any future change involving Cargo member globs, a workspace-root package, an in-repository production path dependency, or a custom production library/binary target path must remain inside this security contract's reviewed manifest/source surface rather than silently widening trust; +- exact-head repository/security checks and independent review must pass with no unresolved authority finding; +- pinned Chromium must later prove create/use/post-condition/destroy behavior rather than treating a command ACK as success. diff --git a/docs/traceability/browser-session-workspace-member-containment.md b/docs/traceability/browser-session-workspace-member-containment.md new file mode 100644 index 000000000..b5504aaf7 --- /dev/null +++ b/docs/traceability/browser-session-workspace-member-containment.md @@ -0,0 +1,42 @@ +# Browser Session workspace-member containment + +- **Status:** active-PR security evidence for PR #317; not protected-main behavior +- **Owner:** OriginWeave Browser Session bounded context +- **Canonical contract:** `tests/test_browser_session_trusted_adapter_boundary.py` +- **Related evidence:** `docs/traceability/browser-session-trusted-adapter-boundary.md`, `docs/THREAT_MODEL.md` + +## Problem + +The trusted-adapter contract treats repository-local Cargo package/source topology as the review boundary for code that can participate in Browser Session lifecycle composition. Local production `path` dependencies and custom target sources already fail closed when their resolved paths escape the repository review root, but explicit `[workspace].members` did not apply the same containment check. + +Cargo's workspace contract allows `members` to name package directories rather than restricting them to a `crates/*` convention. The Cargo Book also documents `package.workspace` specifically for member packages that are not under the workspace root. A relative member such as `../external-browser-adapter` can therefore identify code outside the repository tree. Merely checking that its `Cargo.toml` exists is insufficient for a repository-scoped TCB review contract. + +## RED and repair + +- **Structural RED `44b6d2716ade55c3793698080bfda01ff51a23c1`** adds a hostile workspace fixture whose explicit member resolves to `../external-browser-adapter`. The previous `_workspace_member_manifests()` accepted the external manifest because it checked only existence. +- **Minimal causal repair `a807accfddea31a5739f47dcfb992057f7292c03`** resolves each explicit member manifest and requires it to remain under the repository review root before it may enter the canonical production package closure. A member that escapes the root now raises instead of silently expanding the trusted composition surface. +- **Independent-review finding on `afb8ab82adcb1da564930798f682f10b38cf818b`** accepted the containment implementation, including canonical path resolution, but identified one P2 coverage gap: the committed hostile fixture covered `..` escape but not a repository-local symlink whose target resolves outside the review root. +- **Review-driven regression `ddf17ba21581473c20fdd5c7f7b3223240edd262`** adds `tests/test_browser_session_workspace_member_symlink_contract.py`. The fixture creates a member path inside the workspace that is a directory symlink to an external package and requires the canonical scanner to reject the resolved external manifest. It imports the single-writer scanner rather than creating another topology implementation. +- **Focused independent review of exact `00059590351b69499440dbddd72c4fb36c11305b`** found no remaining defect in this scope. The review verified that the symlink fixture invokes the canonical `_workspace_member_manifests()` scanner, that resolved-path containment rejects the external target, that the traceability chain is accurate, and that the review-driven delta does not modify Browser Session production source, `Cargo.toml`, or `Cargo.lock`. The review explicitly classified this as structural evidence rather than repository-test/CI execution. + +The repair changes repository security coverage only. No production Rust, Browser Session runtime semantics, WebDriver BiDi authority, Chromium behavior, or existing allowlist entry changed. + +## Decision + +External Cargo workspace members are rejected by this repository contract even if Cargo itself can model such membership. The product security boundary is intentionally narrower than Cargo's general project-layout flexibility: code outside the repository cannot be proven by OriginWeave's exact-head review, provenance, branch protection, or release evidence. + +Alternative approaches were rejected: + +- **Accept the external member because Cargo accepts it:** rejected because repository review/provenance cannot establish the external source generation. +- **Copy external code into the scanner's evidence:** rejected because that turns mutable external source into an implicit dependency instead of a released/versioned owner contract. +- **Permit an allowlisted filesystem path outside the repository:** rejected because the path is not an immutable release identity and would bypass normal PR/release provenance. + +If OriginWeave later needs an external browser adapter, it must arrive through a released/versioned dependency or another canonical owner boundary rather than an unversioned workspace-member filesystem edge. + +## Authoritative reference + +The Cargo Book, *Workspaces*, documents `[workspace].members` as package-directory entries, automatic in-workspace path-dependency membership, and `package.workspace` for explicitly identifying a workspace root when the member is not beneath it: https://doc.rust-lang.org/cargo/reference/workspaces.html + +## Remaining acceptance + +The current branch has focused structural review closure for this workspace-member containment slice, but no executable exact-head GREEN is transferred or implied. #229 current exact-head repository/security evidence remains the lineage prerequisite; after authorized ordinary/non-force ancestry reconciliation, #317 must obtain fresh exact-head repository/security checks and full current-head review before #318 → #321 → #316 restacking. Real Chromium acceptance remains downstream under #299 and the canonical `.github` MV3 workflow/sandbox owner path. diff --git a/docs/traceability/webdriver-bidi-publication-current.md b/docs/traceability/webdriver-bidi-publication-current.md index 8fd347776..67179dd2d 100644 --- a/docs/traceability/webdriver-bidi-publication-current.md +++ b/docs/traceability/webdriver-bidi-publication-current.md @@ -1,35 +1,45 @@ # WebDriver BiDi publication-current receipt Status: active standards traceability -Observed: 2026-09-10 +Observed: 2026-09-19 Runtime-compatible pin: `2026-09-03` -Latest published Working Draft: `2026-09-09` +Latest published Working Draft: `2026-09-16` +Previous published Working Draft: `2026-09-14` +Editor's Draft: `https://w3c.github.io/webdriver-bidi/` ## Problem -The `originweave-bidi` presentation capability map is deliberately version-pinned, but its repository contract had conflated that qualified runtime pin with the latest W3C publication. On 2026-09-10 the canonical W3C Technical Report page identifies the 9 September 2026 Working Draft as the latest published version, while the adapter remains qualified against the immutable 3 September 2026 Working Draft. +The `originweave-bidi` presentation capability map is deliberately version-pinned, but publication provenance and runtime qualification are separate facts. A fresh 2026-09-19 read of the canonical W3C publication-history page still identifies the 16 September 2026 Working Draft as the latest published version and the 14 September 2026 Working Draft as the previous published version; no newer dated Working Draft had been published by that observation. The Editor's Draft remains a separate mutable surface. The adapter remains qualified against the immutable 3 September 2026 Working Draft. -Treating those as the same datum creates two bad failure modes: documentation can become false whenever W3C publishes a new draft, or an automation can silently repin the runtime compatibility claim without re-running the browser/protocol qualification that gives the pin meaning. +Treating publication freshness and runtime qualification as the same datum creates two bad failure modes: documentation can become false whenever W3C publishes a new draft, or an automation can silently repin the runtime compatibility claim without re-running the browser/protocol qualification that gives the pin meaning. ## Current authoritative publication Canonical publication page: https://www.w3.org/TR/webdriver-bidi/ -Latest immutable published Working Draft: https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/ +Canonical publication history: https://www.w3.org/standards/history/webdriver-bidi/ -The 9 September publication still exposes the standard presentation/lifecycle surfaces used by OriginWeave's capability analysis, including `browsingContext.setViewport`, `browser.createUserContext` / `browser.removeUserContext`, `emulation.setLocaleOverride`, `emulation.setMediaFeaturesOverride`, `emulation.setScreenSettingsOverride`, `emulation.setTimezoneOverride`, and `emulation.setUserAgentOverride`. Their presence is standards research evidence, not proof that the existing runtime adapter has been requalified against the new publication. +Latest immutable published Working Draft: https://www.w3.org/TR/2026/WD-webdriver-bidi-20260916/ + +Previous immutable published Working Draft: https://www.w3.org/TR/2026/WD-webdriver-bidi-20260914/ + +Mutable Editor's Draft: https://w3c.github.io/webdriver-bidi/ + +The 16 September publication continues to expose the standard presentation/lifecycle surfaces used by OriginWeave's capability analysis, including `browsingContext.setViewport`, `browser.createUserContext` / `browser.removeUserContext`, `emulation.setLocaleOverride`, `emulation.setMediaFeaturesOverride`, `emulation.setScreenSettingsOverride`, `emulation.setTimezoneOverride`, and `emulation.setUserAgentOverride`. Their presence is standards research evidence, not proof that the existing runtime adapter has been requalified against the new publication. ## Runtime compatibility decision -OriginWeave keeps `WEBDRIVER_BIDI_PRESENTATION_REVISION = "2026-09-03"` until a dedicated compatibility change proves that the newer immutable draft preserves the exact command schemas, reset semantics, capability interpretation, browser implementation behavior, and pinned-Chromium acceptance required by the adapter. +OriginWeave keeps `WEBDRIVER_BIDI_PRESENTATION_REVISION = "2026-09-03"` until a dedicated compatibility change proves that a newer immutable draft preserves the exact command schemas, reset semantics, capability interpretation, browser implementation behavior, and pinned-Chromium acceptance required by the adapter. A publication-freshness update therefore does **not** mutate the runtime pin, claim new browser capability, or promote command acknowledgement to presentation evidence. The safe sequence is: 1. record the latest authoritative W3C publication independently from the supported runtime pin; -2. diff the relevant specification surfaces and update the versioned capability map only if needed; -3. re-run repository contracts and pinned Chromium/BiDi/CDP compatibility evidence on the proposed new pin; -4. update architecture/ADR/doctoring compatibility claims together with the qualified pin; -5. keep unsupported or unverified surfaces fail closed. +2. retain the immediately previous immutable publication as provenance for publication-history checks; +3. keep the mutable Editor's Draft explicitly separate from dated Technical Reports; +4. diff the relevant specification surfaces and update the versioned capability map only if needed; +5. re-run repository contracts and pinned Chromium/BiDi/CDP compatibility evidence on any proposed new runtime pin; +6. update architecture/ADR/doctoring compatibility claims together with the qualified pin; +7. keep unsupported or unverified surfaces fail closed. ## Relationship to buyer acceptance @@ -37,7 +47,10 @@ This receipt does not close OriginWeave #292. The buyer-visible acceptance still ## Traceability -- W3C latest published version observed 2026-09-10: WebDriver BiDi Working Draft, 9 September 2026. +- W3C publication history re-read on 2026-09-19: no Working Draft newer than 16 September 2026 was listed. +- W3C latest published version observed 2026-09-19: WebDriver BiDi Working Draft, 16 September 2026. +- Previous published version: WebDriver BiDi Working Draft, 14 September 2026. +- Mutable Editor's Draft: https://w3c.github.io/webdriver-bidi/. - Runtime-qualified OriginWeave adapter pin: WebDriver BiDi Working Draft, 3 September 2026. - OriginWeave buyer acceptance owner: issue #292. - OriginWeave profile/standard-adapter parent lineage: PR #229, which has inherited merged PR #293. @@ -45,6 +58,8 @@ This receipt does not close OriginWeave #292. The buyer-visible acceptance still ## References -World Wide Web Consortium. (2026, September 9). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/ +World Wide Web Consortium. (2026, September 16). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260916/ + +World Wide Web Consortium. (2026, September 14). *WebDriver BiDi* (W3C Working Draft; previous published version). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260914/ World Wide Web Consortium. (2026, September 3). *WebDriver BiDi* (W3C Working Draft; runtime-qualified OriginWeave pin). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/ diff --git a/docs/uml/browser-session-lifecycle-authority.md b/docs/uml/browser-session-lifecycle-authority.md index 5b171cfbf..3f2603723 100644 --- a/docs/uml/browser-session-lifecycle-authority.md +++ b/docs/uml/browser-session-lifecycle-authority.md @@ -2,100 +2,196 @@ This diagram describes the active-PR domain contract for issue #312. It is not evidence that a WebDriver BiDi or Chromium adapter already implements the port. +## Ordinary lifecycle and presentation authority + ```mermaid sequenceDiagram autonumber participant C as Application service participant S as BrowserSession aggregate - participant P as DisposableContextPort + participant BS as BoundBrowserSession + participant P as DisposableContextPort / AuthorizedContextOperationPort participant B as Browser adapter (planned) C->>S: start(valid BrowserSessionId) S->>S: allocate BrowserSessionIncarnation - C->>S: create_disposable_context(port) - S->>S: reserve monotonic context epoch - S->>P: create_disposable_context(session_id, incarnation) - P->>B: create fresh isolation boundary + browsing context - B-->>P: unique isolation id + BrowsingContextId or typed create error - P-->>S: DisposableContextHandle - S->>S: register exact handle + Active epoch - S-->>C: PresentationMutationAuthority(session, incarnation, isolation, context, epoch) - - Note over C,S: Raw BrowserSessionId/BrowsingContextId/user-context id cannot mint authority. - - C->>S: advance_context_epoch(context_id) - S->>S: replace epoch; old authority becomes stale - S-->>C: new opaque authority carrying same incarnation + isolation - - C->>S: destroy_disposable_context(authority, port) - S->>S: validate exact session/incarnation/isolation/context/epoch before I/O - S->>P: destroy_disposable_context(session_id, incarnation, stored handle) + C->>S: bind_lifecycle_port(port by value) + S-->>C: BoundBrowserSession owns aggregate + exact port + Note over S,P: binding invokes no adapter callback; no public raw port accessor + + C->>BS: create_disposable_context() + BS->>S: require Active + reserve monotonic BrowserContextEpoch + S->>S: mint DisposableContextCreateRequest(session, incarnation, attempt epoch) + S->>P: create_disposable_context(request) + P->>B: create/stage isolation boundary + browsing context + B-->>P: DisposableContextHandle or typed create error + P-->>S: candidate remains pending/non-authorizing + + alt domain handle accepted + S->>S: validate no isolation/context alias + S->>S: mint DisposableContextCreateCompletion(Accepted, exact attempt) + S->>P: complete_disposable_context_creation(completion) + P->>P: pending exact attempt → accepted + S->>S: register exact handle + Active epoch + S-->>C: PresentationMutationAuthority(session, incarnation, isolation, context, epoch) + else domain handle rejected/unsettled + S->>S: retain exact non-authorizing recovery evidence + S->>S: retain Active siblings as RecoveryRequiredOwnedHandle + S->>P: DisposableContextCreateCompletion(Rejected, exact attempt) + S->>S: RecoveryRequired + end + + C->>BS: execute_authorized_context_operation(authority, operation) + BS->>S: validate session/incarnation/isolation/context/epoch + alt authority current + BS->>BS: mint private AuthorizedContextOperationRequest + BS->>P: execute_authorized_context_operation(request) + P->>B: adapter-owned presentation command + B-->>P: typed result + P-->>C: output or adapter error + else stale or foreign authority + S-->>C: AuthorizedContextOperationError::BrowserSession + Note over BS,P: adapter I/O = 0 + end + + C->>BS: destroy_disposable_context(authority) + BS->>S: validate exact authority before I/O + S->>P: DisposableContextDestroyRequest(handle, validated epoch) P->>B: remove exact owned isolation boundary - B-->>P: observed destruction post-condition or DisposableContextDestroyError - P-->>S: success - S->>S: context = Destroyed - C->>S: end() - S->>S: require every owned context Destroyed - S-->>C: Ended + alt destruction proved + P-->>S: success + S->>S: remove live hot-ownership record + else DisposableContextDestroyError / cleanup unproven + S->>S: keep record Uncertain + S->>S: retain exact UnprovenDestruction(handle, epoch) + S->>S: RecoveryRequired + end + + C->>BS: finish() + alt no live or uncertain ownership remains + BS->>S: end() + S-->>C: Ended + else ownership remains + S-->>C: ActiveContextRemains + Note over C,P: same BoundBrowserSession + exact adapter retained for cleanup/retry + end ``` -`BrowserSessionIncarnation` separates two sequential aggregate lifecycles even when the browser or adapter later reuses the same external session, user-context/isolation, browsing-context, and local epoch values. The incarnation is checked by authority validation and reaches the lifecycle port. It is therefore not merely an aggregate-local nonce that the adapter can ignore. - -For a WebDriver BiDi adapter, `DisposableIsolationId` maps to the user-context id created by `browser.createUserContext`. That protocol id remains lifecycle addressability rather than OriginWeave policy authority. Creation and destruction expose distinct typed errors. +`BoundBrowserSession` is a linear lifecycle-port binding. It consumes one concrete port, exposes no public raw `&P`, and accepts no replacement port on lifecycle methods. `AuthorizedContextOperationRequest` is privately constructed after exact `PresentationMutationAuthority` validation. A raw browser id, adapter-selected value, diagnostic view, or second adapter cannot mint Browser Session authority. -## Recovery and transport state +## Recovery custody and exact same-adapter recovery ```mermaid stateDiagram-v2 [*] --> Active - Active --> Active: fresh isolation + context / authority minted - Active --> Active: context epoch advanced / prior authority stale - Active --> Active: exact owned isolation destruction proved - Active --> Active: DisposableContextCreateError::CreateFailedClean - Active --> RecoveryRequired: CreateFailedUncertain / retain known partial isolation - Active --> RecoveryRequired: duplicate output / retain offending handle + Active --> Active: create + exact Accepted completion + Active --> Active: current authorized operation + Active --> Active: proven destroy / remove live hot-ownership record + Active --> RecoveryRequired: uncertain create / rejected-unsettled completion Active --> RecoveryRequired: DisposableContextDestroyError / cleanup unproven - Active --> Ended: all owned contexts Destroyed + end - Active --> TransportLost: browser transport lost - RecoveryRequired --> RecoveryRequired: transport_lost = true / preserve recovery evidence - Ended --> [*] - RecoveryRequired --> [*] - TransportLost --> [*] + Active --> TransportLost: transport_lost / TransportLossOwnedHandle + RecoveryRequired --> RecoveryCustody: into_recovery(self) + TransportLost --> RecoveryCustody: into_recovery(self) only with unresolved evidence + RecoveryCustody --> RecoveryCustody: execute_recovery_context_operation(RecoveryFact, operation) + RecoveryCustody --> RecoveryCustody: settle one exact RecoveryFact / revision++ + RecoveryCustody --> Ended: final exact fact + hot ownership retired + Active --> Ended: finish() after proven cleanup note right of RecoveryRequired - BrowserSessionRecoveryEvidence retains known - partial identity, duplicate handle, or exact - unproven-destruction handle. It grants no I/O. + Exact BrowserSessionRecoveryEvidence and + DisposableContextCreateRecoveryEvidence are + non-authorizing. RecoveryRequiredOwnedHandle + preserves indirectly uncertain siblings. end note - note right of TransportLost - Transport liveness is orthogonal to ownership - recovery. Duplicate loss reports are idempotent. + note right of RecoveryCustody + BoundBrowserSessionRecovery

retains the + exact consumed adapter. Recovery commands require + one current Browser Session-issued RecoveryFact; + the adapter sees only that selected fact. + Raw P and ordinary Browser Session authority + remain inaccessible. end note ``` -## Sequential ABA hostile case - ```mermaid sequenceDiagram autonumber - participant A as BrowserSession A - participant B as BrowserSession B - participant P as Lifecycle port - - A->>A: start(S) => incarnation A - A->>P: create(S, incarnation A) - P-->>A: U, C - A->>P: destroy(S, incarnation A, U/C) - A->>A: end() - - B->>B: start(S) => incarnation B - B->>P: create(S, incarnation B) - P-->>B: same U, same C - Note over A,B: both local context epochs may equal 1 - B->>B: validate retained authority A - B-->>A: AuthorityMismatch before adapter I/O - B->>P: destroy with authority B + incarnation B + participant C as Recovery owner / application service + participant R as BoundBrowserSessionRecovery + participant BS as retained BoundBrowserSession + participant P as exact consumed adapter + participant B as Browser / protocol endpoint + + C->>BS: unresolved destroy or transport loss + BS->>BS: RecoveryRequired or TransportLost + exact evidence + C->>BS: into_recovery(self) + BS-->>R: move exact BoundBrowserSession + same adapter; no I/O + + C->>R: recovery_fact(index) or create_attempt_recovery_fact(index) + R-->>C: opaque RecoveryFact(session, incarnation, ledger, index, revision) + C->>R: execute_recovery_context_operation(fact, operation) + R->>R: validate state + session/incarnation + revision + exact current fact + alt fact foreign, stale, or no longer current + R-->>C: AuthorityMismatch or StaleFact + Note over R,P: adapter I/O = 0 + else current exact fact + R->>BS: crate-private dispatch_recovery_operation + BS->>P: RecoveryContextOperationRequest(selected fact + operation) + Note over P: sibling recovery facts are not disclosed + P->>B: adapter-owned purpose-bounded recovery command + B-->>P: result + P-->>R: Output or RecoveryContextOperationError::Adapter + Note over R,BS: success/failure does not clear Browser Session uncertainty + end + + C->>R: settle_recovery_fact(fact, independently qualified proof) + R->>R: revalidate session/incarnation/revision/exact fact + R->>BS: crate-private dispatch_recovery_operation + BS->>P: RecoverySettlementRequest(selected fact + proof) + P->>B: verify protocol-specific proof / post-condition evidence + alt proof rejected + P-->>R: RecoverySettlementError::Adapter + Note over R,BS: no domain fact is retired + else proof accepted + R->>BS: retire exactly selected fact + R->>R: recovery revision++ + Note over R: every previously issued RecoveryFact becomes stale + alt no recovery facts or uncertain hot ownership remain + R->>BS: terminal Ended + end + end + + Note over R,P: no raw P, no generic caller callback, no ordinary create/destroy/presentation authority +``` + +Recovery custody is narrower than protocol reconciliation. #316 remains responsible for WebDriver BiDi pending/accepted/quarantined tuple truth, remote liveness, event correlation, replay qualification, and concrete recovery-command/proof semantics. `RecoveryContextOperationPort` provides the same-consumed-adapter conduit only for one current `RecoveryFact`; adapter success is not destruction proof. `RecoverySettlementPort` independently qualifies proof before Browser Session retires that exact fact. + +Dropping unresolved ordinary or recovery custody performs no browser I/O. `abandoned_bound_session_count()` is a process-local operability signal, not durable exact-handle storage or proof of cleanup. + +## Navigation / presentation interaction + +```mermaid +stateDiagram-v2 + [*] --> Established + Established --> Pending: exact observed navigation start / mint NavigationSettlementAuthority + Pending --> Pending: commit progress + Pending --> Eligible: positive settlement + Pending --> Eligible: Failed or Aborted + Pending --> Eligible: download start + Pending --> Pending: newer navigation supersedes witness + Eligible --> Established: explicit reestablish_presentation_authority / next epoch + Established --> [*]: proven lifecycle destruction + Pending --> [*]: proven lifecycle destruction + Eligible --> [*]: proven lifecycle destruction ``` -`RecoveryRequired` and `TransportLost` remain terminal for normal authority in this slice. A later reconciliation design may inspect `BrowserSessionRecoveryEvidence`, but it must not reconstruct cleanup authority from raw identifiers or treat command ACK as proof of destruction. +Navigation admission is bound to exact `BrowserSessionIncarnation`, `BrowsingContextId`, and current `BrowserContextEpoch`. The opaque navigation witness, not raw WebDriver BiDi navigation ids, controls terminal assignment. A navigation-invalidated `PresentationMutationAuthority` cannot authorize presentation mutation or authority-based cleanup. The exact bound lifecycle owner can still destroy its owned context without reopening presentation authority. + +## Same-raw-identity and sequential ABA hostile cases + +A single aggregate may create → prove destroy → recreate the same raw isolation/browsing-context values for **258 ownership generations**. Hot command-authority state remains bounded to live/uncertain ownership. The predecessor authority fails as `ContextNotOwned` immediately after destruction and as `AuthorityMismatch` after same-raw-id recreation because the epoch is monotonic. + +Across aggregate restart/recreation, `BrowserSessionIncarnation` prevents a retained authority from aggregate A from becoming valid in aggregate B even when raw `BrowserSessionId`, isolation, browsing-context id, and local epoch numerically alias. + +`RecoveryRequired` and `TransportLost` remain closed to ordinary lifecycle and presentation authority. `into_recovery(self)` is a one-way custody transfer, not command-authority resurrection. A recovery command additionally requires a current opaque `RecoveryFact`; command ACK never settles that fact. Proof-bearing settlement can only retire the selected fact after independent adapter verification, and complete settlement reaches terminal `Ended` rather than reopening ordinary authority. diff --git a/docs/uml/browser-session-navigation-authority.md b/docs/uml/browser-session-navigation-authority.md new file mode 100644 index 000000000..e66e466ea --- /dev/null +++ b/docs/uml/browser-session-navigation-authority.md @@ -0,0 +1,83 @@ +# Browser Session navigation authority UML + +Status: `IMPLEMENTED_ON_ACTIVE_PR` on #317. This diagram describes Browser Session domain state, not WebDriver BiDi adapter tuple state. + +```mermaid +stateDiagram-v2 + [*] --> Established: accepted owned context + + Established --> Pending: record_observed_navigation\nexact incarnation/context/epoch\nzero I/O, no presentation epoch + Pending --> Pending: first qualified commit\ncommitted = true + Pending --> Pending: newer navigation start\nsupersedes old witness\nnew navigation generation + Pending --> Eligible: positive settlement + Pending --> Eligible: Aborted / Failed + Pending --> Eligible: download start + Eligible --> Pending: newer navigation start\nsupersedes unused eligibility + Eligible --> Established: reestablish_presentation_authority\nreserve next BrowserContextEpoch + + Established --> Removed: proven lifecycle-owner destruction + Pending --> Removed: proven lifecycle-owner destruction + Eligible --> Removed: proven lifecycle-owner destruction + + Established --> RecoveryRequired: unproven destruction / ownership failure + Pending --> RecoveryRequired: unproven destruction / ownership failure + Eligible --> RecoveryRequired: unproven destruction / ownership failure + + Established --> TransportLost: transport loss + Pending --> TransportLost: transport loss + Eligible --> TransportLost: transport loss + + RecoveryRequired --> [*]: recovery-owner handoff / later canonical recovery + TransportLost --> [*]: recovery-owner handoff / later canonical recovery + Removed --> [*] +``` + +The `Pending` state is qualified by a private monotonic `navigation_generation` and a `committed` bit. `Eligible` is context-local and represents exactly one unused opportunity to re-establish presentation authority. Neither commit nor terminal closure advances `BrowserContextEpoch`; only successful explicit re-establishment does. + +```mermaid +sequenceDiagram + participant BiDi as #316 BiDi adapter + participant Session as BoundBrowserSession + participant Aggregate as BrowserSession + participant Port as Same consumed adapter + + BiDi->>Session: qualified navigationStarted(incarnation, context, epoch) + Session->>Aggregate: begin_observed_navigation(...) + Aggregate-->>Session: opaque NavigationSettlementAuthority + Note over Session,Aggregate: presentation authority revoked; zero adapter I/O + + BiDi->>Session: qualified navigationCommitted(witness) + Session->>Aggregate: mark_observed_navigation_committed(witness) + Aggregate-->>Session: commit progress only + + BiDi->>Session: complete | abort | fail | download(witness) + Session->>Aggregate: close_observed_navigation(witness) + Aggregate-->>Session: Eligible + + BiDi->>Session: reestablish_presentation_authority(witness) + Session->>Aggregate: validate current Eligible witness + Aggregate-->>Session: new PresentationMutationAuthority(next epoch) + + alt lifecycle cleanup is required while presentation is revoked + BiDi->>Session: destroy_owned_disposable_context(context) + Session->>Aggregate: validate exact lifecycle custody + Aggregate->>Port: opaque DisposableContextDestroyRequest(handle, epoch) + Port-->>Aggregate: destruction result + Aggregate-->>Session: remove exact owned generation or enter RecoveryRequired + end +``` + +## Capability boundary + +- `NavigationSettlementAuthority` is opaque and caller-unconstructible; raw BiDi navigation/context identifiers are evidence only. +- `PresentationMutationAuthority` becomes stale immediately when a qualified navigation is admitted. +- A stale presentation capability cannot be reused for mutation or authority-based cleanup. +- `destroy_owned_disposable_context` is a lifecycle-owner path, not a presentation-authority bypass. It acts only on the exact current owned generation through the already-consumed adapter. +- `RecoveryRequired`, `TransportLost`, ended sessions, destroyed generations, foreign incarnations, stale epochs, and superseded navigation generations fail closed before browser I/O. +- #316 owns protocol event qualification, correlation, replay handling, transport loss, and remote-liveness interpretation. It must not duplicate this Browser Session state machine. + +## Acceptance boundary + +#318 and #321 exercise the wider hostile matrix—cross-context/cross-incarnation replay, supersession, terminal single assignment, download ordering, sibling destruction, same-raw-id recreation, and cumulative stale-capability rejection. They are acceptance successors, not alternate production owners. + +Real Chromium success additionally requires browser-observed post-conditions and cleanup evidence. A command ACK alone does not transition this UML to buyer-visible GREEN. diff --git a/tests/test_browser_session_architecture_incarnation_contract.py b/tests/test_browser_session_architecture_incarnation_contract.py new file mode 100644 index 000000000..16ade3189 --- /dev/null +++ b/tests/test_browser_session_architecture_incarnation_contract.py @@ -0,0 +1,18 @@ +from pathlib import Path + + +ARCHITECTURE = Path("ARCHITECTURE.md") + + +def _browser_session_section() -> str: + text = ARCHITECTURE.read_text(encoding="utf-8") + return text.split("### `originweave-browser-session` (active PR)", 1)[1].split("## 6. Planned modules", 1)[0] + + +def test_browser_session_architecture_names_incarnation_as_aba_discriminator() -> None: + section = _browser_session_section() + + assert "`BrowserSessionIncarnation`" in section + assert "sequential ABA" in section + assert "participates in authorization validation" in section + assert "The isolation identity prevents distinct aggregate incarnations" not in section diff --git a/tests/test_browser_session_build_surface_contract.py b/tests/test_browser_session_build_surface_contract.py new file mode 100644 index 000000000..4fe893eef --- /dev/null +++ b/tests/test_browser_session_build_surface_contract.py @@ -0,0 +1,150 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +BOUNDARY_TEST = ROOT / "tests/test_browser_session_trusted_adapter_boundary.py" + +spec = importlib.util.spec_from_file_location("browser_session_trusted_adapter_boundary", BOUNDARY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session trusted-adapter boundary contract") +boundary = importlib.util.module_from_spec(spec) +spec.loader.exec_module(boundary) + + +class BrowserSessionBuildSurfaceContractTests(unittest.TestCase): + """Keep generated-code and source-override build surfaces out of the Browser Session TCB closure.""" + + def _workspace(self, manifest_suffix: str = "") -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n' + + manifest_suffix, + encoding="utf-8", + ) + return directory, root + + def _write_cargo_config(self, directory: pathlib.Path, name: str, content: str) -> None: + cargo = directory / ".cargo" + cargo.mkdir(exist_ok=True) + (cargo / name).write_text(content, encoding="utf-8") + + def test_default_build_rs_fails_closed(self) -> None: + directory, root = self._workspace() + with directory: + (root / "adapter/build.rs").write_text("fn main() {}\n", encoding="utf-8") + with self.assertRaisesRegex(AssertionError, "production Cargo build script"): + boundary._production_package_manifests(root) + + def test_custom_package_build_path_fails_closed(self) -> None: + directory, root = self._workspace('build = "tools/generate.rs"\n') + with directory: + (root / "adapter/tools").mkdir() + (root / "adapter/tools/generate.rs").write_text("fn main() {}\n", encoding="utf-8") + with self.assertRaisesRegex(AssertionError, "production Cargo build script"): + boundary._production_package_manifests(root) + + def test_build_dependencies_fail_closed(self) -> None: + directory, root = self._workspace('[build-dependencies]\nserde = "1"\n') + with directory: + with self.assertRaisesRegex(AssertionError, "production Cargo build dependencies"): + boundary._production_package_manifests(root) + + def test_target_specific_build_dependencies_fail_closed(self) -> None: + directory, root = self._workspace( + "[target.'cfg(unix)'.build-dependencies]\nserde = \"1\"\n" + ) + with directory: + with self.assertRaisesRegex(AssertionError, "production Cargo build dependencies"): + boundary._production_package_manifests(root) + + def test_workspace_patch_override_fails_closed(self) -> None: + directory, root = self._workspace() + with directory: + patched = root / "patched-adapter" + (patched / "src").mkdir(parents=True) + (patched / "src/lib.rs").write_text("pub fn patched() {}\n", encoding="utf-8") + (patched / "Cargo.toml").write_text( + '[package]\nname = "patched-adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n\n' + '[patch.crates-io]\npatched-adapter = { path = "patched-adapter" }\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(AssertionError, "Cargo source override"): + boundary._production_package_manifests(root) + + def test_workspace_replace_override_fails_closed(self) -> None: + directory, root = self._workspace() + with directory: + replacement = root / "replacement-adapter" + (replacement / "src").mkdir(parents=True) + (replacement / "src/lib.rs").write_text("pub fn replacement() {}\n", encoding="utf-8") + (replacement / "Cargo.toml").write_text( + '[package]\nname = "replacement-adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n\n' + '[replace]\n"replacement-adapter:0.1.0" = { path = "replacement-adapter" }\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(AssertionError, "Cargo source override"): + boundary._production_package_manifests(root) + + def test_repository_cargo_config_paths_override_fails_closed(self) -> None: + directory, root = self._workspace() + with directory: + self._write_cargo_config(root, "config.toml", 'paths = ["../external-adapter"]\n') + with self.assertRaisesRegex(AssertionError, "Cargo config source override"): + boundary._production_package_manifests(root) + + def test_repository_extensionless_cargo_config_patch_fails_closed(self) -> None: + directory, root = self._workspace() + with directory: + self._write_cargo_config( + root, + "config", + '[patch.crates-io]\nadapter = { path = "../patched-adapter" }\n', + ) + with self.assertRaisesRegex(AssertionError, "Cargo config source override"): + boundary._production_package_manifests(root) + + def test_repository_cargo_config_source_replacement_fails_closed(self) -> None: + directory, root = self._workspace() + with directory: + self._write_cargo_config( + root, + "config.toml", + '[source.crates-io]\nreplace-with = "vendored"\n\n' + '[source.vendored]\ndirectory = "vendor"\n', + ) + with self.assertRaisesRegex(AssertionError, "Cargo config source override"): + boundary._production_package_manifests(root) + + def test_nested_git_owned_cargo_config_override_fails_closed(self) -> None: + directory, root = self._workspace() + with directory: + self._write_cargo_config( + root / "adapter", + "config.toml", + 'paths = ["../../external-adapter"]\n', + ) + with self.assertRaisesRegex(AssertionError, "Cargo config source override"): + boundary._production_package_manifests(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_cargo_build_std_authority_contract.py b/tests/test_browser_session_cargo_build_std_authority_contract.py new file mode 100644 index 000000000..a3fc2882e --- /dev/null +++ b/tests/test_browser_session_cargo_build_std_authority_contract.py @@ -0,0 +1,63 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionCargoBuildStdAuthorityContractTests(unittest.TestCase): + """Keep repository-selected standard-library source builds outside the Browser Session TCB.""" + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def test_repository_build_std_source_selection_fails_closed(self) -> None: + for config_text in ( + '[unstable]\nbuild-std = ["core", "alloc", "std"]\n', + '[unstable]\nbuild-std = true\n', + ): + with self.subTest(config_text=config_text): + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_build_std_features_fails_closed(self) -> None: + root = self._workspace_with_config( + '[unstable]\nbuild-std-features = ["backtrace", "panic-unwind"]\n' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_unrelated_unstable_setting_remains_allowed(self) -> None: + root = self._workspace_with_config('[unstable]\nmtime-on-use = true\n') + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_cargo_compiler_authority_contract.py b/tests/test_browser_session_cargo_compiler_authority_contract.py new file mode 100644 index 000000000..15e1c203e --- /dev/null +++ b/tests/test_browser_session_cargo_compiler_authority_contract.py @@ -0,0 +1,971 @@ +import importlib.util +import pathlib +import tempfile +import tomllib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +BOUNDARY_TEST = ROOT / "tests/test_browser_session_trusted_adapter_boundary.py" + +spec = importlib.util.spec_from_file_location("browser_session_trusted_adapter_boundary", BOUNDARY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session trusted-adapter boundary contract") +boundary = importlib.util.module_from_spec(spec) +spec.loader.exec_module(boundary) + +COMPILER_EXECUTION_KEYS = frozenset( + {"rustc", "rustc-wrapper", "rustc-workspace-wrapper", "rustdoc"} +) +TARGET_EXECUTION_KEYS = frozenset({"linker", "runner"}) +UNSTABLE_TOOLCHAIN_INPUT_KEYS = frozenset( + {"build-std", "build-std-features", "codegen-backend"} +) +LINKER_PLUGIN_OPTIONS = frozenset({"-plugin", "--plugin", "--load-pass-plugin"}) +LINKER_SCRIPT_OPTIONS = frozenset( + {"-T", "--script", "-dT", "--default-script", "-c", "--mri-script"} +) +LINKER_SYMBOL_POLICY_FILE_OPTIONS = frozenset( + { + "--version-script", + "--dynamic-list", + "--retain-symbols-file", + "--export-dynamic-symbol-list", + } +) +LINKER_LAYOUT_PROFILE_FILE_OPTIONS = frozenset( + { + "--call-graph-ordering-file", + "-call-graph-ordering-file", + "--irpgo-profile", + "--symbol-ordering-file", + "--lto-sample-profile", + "--plugin-opt=sample-profile", + "-plugin-opt=sample-profile", + "--lto-cs-profile-file", + "--plugin-opt=cs-profile-path", + "-plugin-opt=cs-profile-path", + } +) +LINKER_OPTIONS_WITH_SEPARATE_OPERAND = frozenset({"-z"}) +LINKER_PLUGIN_LTO_BOOLEAN_VALUES = frozenset( + {"y", "yes", "on", "true", "n", "no", "off", "false"} +) +RUSTDOC_DOCUMENTATION_INPUT_OPTIONS = frozenset( + { + "--html-in-header", + "--html-before-content", + "--html-after-content", + "--markdown-before-content", + "--markdown-after-content", + "--index-page", + "--extend-css", + "--theme", + "--check-theme", + "--read-doc-meta-dir", + "--with-examples", + } +) + + +def _linker_driver_argument_selects_executable(argument: str) -> bool: + """Return whether one compiler-driver argument can re-select driver execution authority.""" + if argument.startswith("@"): + return True + if argument == "-specs" or argument.startswith("-specs="): + return True + if argument == "-wrapper": + return True + if argument.startswith("-fuse-ld="): + return True + return argument == "-B" or argument.startswith("-B") + + +def _linker_argument_extends_external_inputs(argument: str) -> bool: + """Return whether one compiler/linker-driver argument widens external library inputs.""" + if argument == "--sysroot" or argument.startswith("--sysroot="): + return True + if argument in {"-L", "-l", "--library", "--library-path"}: + return True + if argument.startswith(("--library=", "--library-path=")): + return True + if argument.startswith("-L") and len(argument) > 2: + return True + return argument.startswith("-l") and len(argument) > 2 and not argument.startswith("--") + + +def _rustc_argument_extends_external_inputs(argument: str) -> bool: + """Return whether one rustc/rustdoc argument widens opaque or external compiler inputs.""" + if argument.startswith("@"): + return True + if argument == "--sysroot" or argument.startswith("--sysroot="): + return True + if argument == "--extern" or argument.startswith("--extern="): + return True + return _linker_argument_extends_external_inputs(argument) + + +def _linker_option_loads_plugin(argument: str) -> bool: + """Return whether one direct linker option requests dynamically loaded plugin code.""" + if argument in LINKER_PLUGIN_OPTIONS: + return True + return argument.startswith(("-plugin=", "--plugin=", "--load-pass-plugin=")) + + +def _linker_option_selects_error_handler(argument: str) -> bool: + """Return whether one LLD option selects an executable error-handler script.""" + return argument == "--error-handling-script" or argument.startswith( + "--error-handling-script=" + ) + + +def _linker_option_selects_dtlto_executable(argument: str) -> bool: + """Return whether one LLD DTLTO option selects or controls a subprocess.""" + if argument in { + "--thinlto-distributor-arg", + "--thinlto-remote-compiler-prepend-arg", + "--thinlto-remote-compiler-arg", + }: + return True + return argument.startswith( + ( + "--thinlto-distributor=", + "--thinlto-distributor-arg=", + "--thinlto-remote-compiler=", + "--thinlto-remote-compiler-prepend-arg=", + "--thinlto-remote-compiler-arg=", + ) + ) + + +def _linker_option_forwards_llvm_options(argument: str) -> bool: + """Return whether one LLD option forwards opaque arguments to LLVM option processing.""" + if argument in {"--mllvm", "-mllvm"}: + return True + return argument.startswith(("--mllvm=", "-mllvm=", "--plugin-opt=-", "-plugin-opt=-")) + + +def _linker_option_selects_script(argument: str) -> bool: + """Return whether one linker option selects a script that can introduce link inputs.""" + if argument in LINKER_SCRIPT_OPTIONS or argument in { + "--section-ordering-file", + "-section-ordering-file", + }: + return True + return ( + (argument.startswith("-T") and len(argument) > 2) + or (argument.startswith("-dT") and len(argument) > 3) + or argument.startswith( + ( + "--script=", + "--default-script=", + "--mri-script=", + "--section-ordering-file=", + "-section-ordering-file=", + ) + ) + ) + + +def _linker_option_selects_symbol_policy_file(argument: str) -> bool: + """Return whether a linker option consumes an external symbol-policy file.""" + if argument in LINKER_SYMBOL_POLICY_FILE_OPTIONS: + return True + return argument.startswith(tuple(f"{option}=" for option in LINKER_SYMBOL_POLICY_FILE_OPTIONS)) + + +def _linker_option_selects_layout_profile_file(argument: str) -> bool: + """Return whether an LLD option consumes an external layout or profile file.""" + if argument in LINKER_LAYOUT_PROFILE_FILE_OPTIONS: + return True + return argument.startswith( + tuple(f"{option}=" for option in LINKER_LAYOUT_PROFILE_FILE_OPTIONS) + ) + + +def _linker_option_selects_thinlto_cache(argument: str) -> bool: + """Return whether LLD may read native objects from a mutable ThinLTO cache directory.""" + return argument.startswith("--thinlto-cache-dir=") + + +def _linker_option_selects_cmse_import_library(argument: str) -> bool: + """Return whether LLD consumes an existing CMSE secure-code import library.""" + return argument == "--in-implib" or argument.startswith("--in-implib=") + + +def _linker_option_selects_just_symbols_or_rpath(argument: str) -> bool: + """Return whether GNU-compatible linker syntax selects an external -R/just-symbols path.""" + if argument in {"-R", "--just-symbols"}: + return True + if argument.startswith("--just-symbols="): + return True + return argument.startswith("-R") and len(argument) > 2 + + +def _linker_option_controls_runtime_loader(argument: str) -> bool: + """Return whether GNU-compatible linker syntax changes the ELF load-time interpreter.""" + if argument in {"-I", "--dynamic-linker", "--no-dynamic-linker"}: + return True + if argument.startswith("--dynamic-linker="): + return True + return argument.startswith("-I") and len(argument) > 2 + + +def _linker_option_selects_runtime_audit_library(argument: str) -> bool: + """Return whether GNU-compatible linker syntax selects an ELF rtld-audit library.""" + if argument in {"--audit", "-audit", "--depaudit", "-depaudit", "-P"}: + return True + if argument.startswith(("--audit=", "-audit=", "--depaudit=", "-depaudit=")): + return True + return argument.startswith("-P") and len(argument) > 2 + + +def _linker_option_selects_runtime_filter_library(argument: str) -> bool: + """Return whether GNU-compatible linker syntax selects ELF auxiliary/filter runtime code.""" + if argument in {"-f", "-F", "--auxiliary", "-auxiliary", "--filter", "-filter"}: + return True + if argument.startswith(("--auxiliary=", "-auxiliary=", "--filter=", "-filter=")): + return True + if argument.startswith("-fini="): + return False + return ( + (argument.startswith("-f") and len(argument) > 2 and not argument.startswith("--")) + or (argument.startswith("-F") and len(argument) > 2) + ) + + +def _linker_option_selects_runtime_search_path(argument: str) -> bool: + """Return whether GNU-compatible linker syntax selects runtime/link-time shared-library paths.""" + if argument in {"-rpath", "--rpath", "-rpath-link", "--rpath-link", "-Y"}: + return True + if argument.startswith(("-rpath=", "--rpath=", "-rpath-link=", "--rpath-link=")): + return True + return argument.startswith("-Y") and len(argument) > 2 + + +def _linker_option_remaps_inputs(argument: str) -> bool: + """Return whether GNU-compatible linker syntax rewrites reviewed input-file selection.""" + if argument in {"--remap-inputs", "-remap-inputs", "--remap-inputs-file", "-remap-inputs-file"}: + return True + return argument.startswith( + ("--remap-inputs=", "-remap-inputs=", "--remap-inputs-file=", "-remap-inputs-file=") + ) + + +def _linker_option_uses_response_file(argument: str) -> bool: + """Return whether a direct-linker argument delegates parsing to an opaque response file.""" + return argument.startswith("@") + + +def _codegen_option_selects_linker_plugin(option: str) -> bool: + """Return whether one rustc codegen option names an explicit linker-plugin artifact.""" + if not option.startswith("linker-plugin-lto="): + return False + value = option.partition("=")[2] + return value not in LINKER_PLUGIN_LTO_BOOLEAN_VALUES + + +def _codegen_option_extends_external_inputs(option: str) -> bool: + """Return whether one rustc codegen option consumes external or mutable compiler input.""" + return option.startswith(("incremental=", "profile-use=", "profile-sample-use=")) + + +def _linker_argument_is_positional_native_input(argument: str) -> bool: + """Return whether one unconsumed linker token is a positional external input.""" + return bool(argument) and not argument.startswith("-") + + +def _direct_linker_arguments_extend_authority(arguments: tuple[str, ...] | list[str]) -> bool: + """Parse direct-linker tokens without misclassifying modeled option operands as inputs.""" + index = 0 + while index < len(arguments): + argument = arguments[index] + if argument in LINKER_OPTIONS_WITH_SEPARATE_OPERAND: + if index + 1 >= len(arguments): + return True + index += 2 + continue + if ( + _linker_option_loads_plugin(argument) + or _linker_option_selects_error_handler(argument) + or _linker_option_selects_dtlto_executable(argument) + or _linker_option_forwards_llvm_options(argument) + or _linker_option_selects_script(argument) + or _linker_option_selects_symbol_policy_file(argument) + or _linker_option_selects_layout_profile_file(argument) + or _linker_option_selects_thinlto_cache(argument) + or _linker_option_selects_cmse_import_library(argument) + or _linker_option_selects_just_symbols_or_rpath(argument) + or _linker_option_controls_runtime_loader(argument) + or _linker_option_selects_runtime_audit_library(argument) + or _linker_option_selects_runtime_filter_library(argument) + or _linker_option_selects_runtime_search_path(argument) + or _linker_option_remaps_inputs(argument) + or _linker_option_uses_response_file(argument) + or _linker_argument_extends_external_inputs(argument) + or _linker_argument_is_positional_native_input(argument) + ): + return True + index += 1 + return False + + +def _forwarded_linker_arguments(argument: str) -> tuple[str, ...]: + """Return direct-linker arguments encoded by a single compiler-driver forwarding option.""" + if argument.startswith("-Wl,"): + return tuple(argument.removeprefix("-Wl,").split(",")) + if argument.startswith("--for-linker="): + return tuple(argument.removeprefix("--for-linker=").split(",")) + return () + + +def _linker_driver_arguments_select_executable(arguments: list[str]) -> bool: + """Return whether driver arguments can replace tools, extend link inputs, or load linker code.""" + direct_arguments: list[str] = [] + xlinker_arguments: list[str] = [] + index = 0 + while index < len(arguments): + argument = arguments[index] + if _linker_driver_argument_selects_executable(argument): + return True + if _linker_argument_extends_external_inputs(argument): + return True + + forwarded = _forwarded_linker_arguments(argument) + if forwarded and _direct_linker_arguments_extend_authority(forwarded): + return True + + if argument == "-Xlinker": + if index + 1 >= len(arguments): + return True + xlinker_arguments.append(arguments[index + 1]) + index += 2 + continue + + direct_arguments.append(argument) + index += 1 + + return _direct_linker_arguments_extend_authority( + direct_arguments + ) or _direct_linker_arguments_extend_authority(xlinker_arguments) + + +def _flag_arguments(value: object) -> list[str]: + """Normalize one Cargo rustflags value without inventing shell semantics.""" + if isinstance(value, str): + return value.split() + if isinstance(value, list) and all(isinstance(argument, str) for argument in value): + return value + return [] + + +def _flags_select_ambient_host_cpu(value: object) -> bool: + """Return whether Git-owned flags make code generation depend on the runner CPU.""" + arguments = _flag_arguments(value) + for index, argument in enumerate(arguments): + option: str | None = None + if argument in {"-C", "--codegen"} and index + 1 < len(arguments): + option = arguments[index + 1] + elif argument.startswith("-C") and len(argument) > 2: + option = argument[2:] + elif argument.startswith("--codegen="): + option = argument.removeprefix("--codegen=") + if option == "target-cpu=native": + return True + if argument == "-Z" and index + 1 < len(arguments): + if arguments[index + 1] == "tune-cpu=native": + return True + if argument == "-Ztune-cpu=native": + return True + return False + + +def _flags_extend_external_link_inputs(value: object) -> bool: + """Return whether Git-owned Rust flags widen external compiler/documentation inputs.""" + arguments = _flag_arguments(value) + for index, argument in enumerate(arguments): + if _rustc_argument_extends_external_inputs(argument): + return True + + option: str | None = None + if argument in {"-C", "--codegen"} and index + 1 < len(arguments): + option = arguments[index + 1] + elif argument.startswith("-C") and len(argument) > 2: + option = argument[2:] + elif argument.startswith("--codegen="): + option = argument.removeprefix("--codegen=") + + if option is not None and _codegen_option_extends_external_inputs(option): + return True + return False + + +def _flags_select_codegen_backend(value: object) -> bool: + """Return whether Git-owned flags replace or bypass the reviewed code-generation surface.""" + arguments = _flag_arguments(value) + for index, argument in enumerate(arguments): + if argument.startswith(("-Zcodegen-backend=", "-Zllvm-plugins=")): + return True + if ( + argument == "-Z" + and index + 1 < len(arguments) + and arguments[index + 1].startswith(("codegen-backend=", "llvm-plugins=")) + ): + return True + + option: str | None = None + if argument in {"-C", "--codegen"} and index + 1 < len(arguments): + option = arguments[index + 1] + elif argument.startswith("-C") and len(argument) > 2: + option = argument[2:] + elif argument.startswith("--codegen="): + option = argument.removeprefix("--codegen=") + if option == "llvm-args" or (option is not None and option.startswith("llvm-args=")): + return True + return False + + +def _flags_select_linker(value: object) -> bool: + """Return whether Cargo-owned rustc/rustdoc flags extend linker execution or input authority.""" + arguments = _flag_arguments(value) + if not arguments: + return False + + linker_driver_arguments: list[str] = [] + for index, argument in enumerate(arguments): + option: str | None = None + if argument in {"-C", "--codegen"} and index + 1 < len(arguments): + option = arguments[index + 1] + elif argument.startswith("-C") and len(argument) > 2: + option = argument[2:] + elif argument.startswith("--codegen="): + option = argument.removeprefix("--codegen=") + + if option is None: + continue + if option.startswith("linker="): + return True + if option == "link-self-contained" or option.startswith("link-self-contained="): + return True + if option == "linker-features" or option.startswith("linker-features="): + return True + if option == "linker-flavor" or option.startswith("linker-flavor="): + return True + if option == "dlltool" or option.startswith("dlltool="): + return True + if _codegen_option_selects_linker_plugin(option): + return True + if option.startswith("link-arg="): + linker_driver_arguments.append(option.partition("=")[2]) + elif option.startswith("link-args="): + linker_driver_arguments.extend(option.partition("=")[2].split()) + + return _linker_driver_arguments_select_executable(linker_driver_arguments) + + +def _flags_select_rustdoc_test_execution(value: object) -> bool: + """Return whether Git-owned rustdoc flags select external doctest executables.""" + selectors = ("--test-runtool", "--test-builder", "--test-builder-wrapper") + return any( + argument in selectors or argument.startswith(tuple(f"{selector}=" for selector in selectors)) + for argument in _flag_arguments(value) + ) + + +def _flags_select_rustdoc_documentation_input(value: object) -> bool: + """Return whether Git-owned rustdoc flags load external files/directories into documentation generation.""" + long_equals_prefixes = tuple(f"{option}=" for option in RUSTDOC_DOCUMENTATION_INPUT_OPTIONS) + for argument in _flag_arguments(value): + if argument in RUSTDOC_DOCUMENTATION_INPUT_OPTIONS: + return True + if argument.startswith(long_equals_prefixes): + return True + if argument == "-e" or (argument.startswith("-e") and not argument.startswith("--")): + return True + return False + + +def _flags_select_rustdoc_doctest_compiler_authority(value: object) -> bool: + """Return whether rustdoc forwards authority-extending arguments to a doctest compiler.""" + arguments = _flag_arguments(value) + forwarded: list[str] = [] + index = 0 + while index < len(arguments): + argument = arguments[index] + if argument == "--doctest-build-arg": + if index + 1 >= len(arguments): + return True + forwarded.append(arguments[index + 1]) + index += 2 + continue + if argument.startswith("--doctest-build-arg="): + forwarded.append(argument.partition("=")[2]) + index += 1 + + if not forwarded: + return False + return ( + _flags_select_codegen_backend(forwarded) + or _flags_select_linker(forwarded) + or _flags_extend_external_link_inputs(forwarded) + or _flags_select_ambient_host_cpu(forwarded) + ) + + +def _configured_unstable_toolchain_inputs(value: object) -> list[str]: + """Return Git-owned unstable Cargo settings that alter compiler or standard-library inputs.""" + if not isinstance(value, dict): + return [] + configured: list[str] = [] + for key in sorted(UNSTABLE_TOOLCHAIN_INPUT_KEYS.intersection(value)): + setting = value[key] + if setting is False or setting == []: + continue + configured.append(key) + return configured + + +def _configured_profile_codegen_backends(value: object, prefix: str = "profile") -> list[str]: + """Return Cargo profile paths that select a non-default rustc code generation backend.""" + if not isinstance(value, dict): + return [] + configured: list[str] = [] + for key, setting in value.items(): + path = f"{prefix}.{key}" + if key == "codegen-backend": + if setting not in (None, ""): + configured.append(path) + continue + if isinstance(setting, dict): + configured.extend(_configured_profile_codegen_backends(setting, path)) + return sorted(configured) + + +def _configured_profile_rustflag_authority(value: object, prefix: str = "profile") -> list[str]: + """Return profile rustflags that widen compiler execution or external input authority.""" + if not isinstance(value, dict): + return [] + configured: list[str] = [] + for key, setting in value.items(): + path = f"{prefix}.{key}" + if key == "rustflags": + if _flags_select_codegen_backend(setting): + configured.append(f"{path}:codegen backend") + if _flags_select_linker(setting): + configured.append(f"{path}:codegen linker") + if _flags_extend_external_link_inputs(setting): + configured.append(f"{path}:external compiler input") + if _flags_select_ambient_host_cpu(setting): + configured.append(f"{path}:ambient host cpu") + continue + if isinstance(setting, dict): + configured.extend(_configured_profile_rustflag_authority(setting, path)) + return sorted(configured) + + +def _configured_host_execution_authority( + value: object, + prefix: str = "host", + depth: int = 0, +) -> list[str]: + """Return nightly Cargo host-target settings that widen execution or compiler-input authority.""" + if not isinstance(value, dict): + return [] + configured: list[str] = [] + host_setting_keys = TARGET_EXECUTION_KEYS | {"rustflags", "rustdocflags"} + if depth > 0 and not value: + configured.append(f"{prefix}:links build-script override") + for key in sorted(TARGET_EXECUTION_KEYS.intersection(value)): + configured.append(f"{prefix}.{key}") + + rustflags = value.get("rustflags") + if _flags_select_codegen_backend(rustflags): + configured.append(f"{prefix}.rustflags:codegen backend") + if _flags_select_linker(rustflags): + configured.append(f"{prefix}.rustflags:codegen linker") + if _flags_extend_external_link_inputs(rustflags): + configured.append(f"{prefix}.rustflags:external compiler input") + if _flags_select_ambient_host_cpu(rustflags): + configured.append(f"{prefix}.rustflags:ambient host cpu") + + rustdocflags = value.get("rustdocflags") + if _flags_select_codegen_backend(rustdocflags): + configured.append(f"{prefix}.rustdocflags:codegen backend") + if _flags_select_linker(rustdocflags): + configured.append(f"{prefix}.rustdocflags:codegen linker") + if _flags_extend_external_link_inputs(rustdocflags): + configured.append(f"{prefix}.rustdocflags:external compiler input") + if _flags_select_ambient_host_cpu(rustdocflags): + configured.append(f"{prefix}.rustdocflags:ambient host cpu") + if _flags_select_rustdoc_test_execution(rustdocflags): + configured.append(f"{prefix}.rustdocflags:doctest execution") + if _flags_select_rustdoc_documentation_input(rustdocflags): + configured.append(f"{prefix}.rustdocflags:documentation input") + if _flags_select_rustdoc_doctest_compiler_authority(rustdocflags): + configured.append(f"{prefix}.rustdocflags:doctest compiler authority") + + if depth > 0 and any( + key not in host_setting_keys and not isinstance(setting, dict) + for key, setting in value.items() + ): + configured.append(f"{prefix}:links build-script override") + + for key, setting in value.items(): + if key in host_setting_keys or not isinstance(setting, dict): + continue + configured.extend( + _configured_host_execution_authority(setting, f"{prefix}.{key}", depth + 1) + ) + return sorted(configured) + + +def _configured_custom_target_specs(value: object) -> list[str]: + """Return repository-selected custom rustc target specification paths.""" + if isinstance(value, str): + targets = [value] + elif isinstance(value, list) and all(isinstance(target, str) for target in value): + targets = value + else: + return [] + return sorted(target for target in targets if target.endswith(".json")) + + +def _assert_no_repository_cargo_compiler_execution_overrides(root: pathlib.Path) -> None: + """Reject Git-owned Cargo settings that replace Rust tools or widen compiler inputs.""" + # The trusted-adapter boundary remains the single writer for production package/source topology + # and dependency-source overrides. This contract owns Cargo-selected execution/input authority. + boundary._production_package_manifests(root) + + root_manifest_path = root / "Cargo.toml" + root_manifest = tomllib.loads(root_manifest_path.read_text(encoding="utf-8")) + manifest_profile_codegen_backends = _configured_profile_codegen_backends( + root_manifest.get("profile") + ) + manifest_profile_rustflag_authority = _configured_profile_rustflag_authority( + root_manifest.get("profile") + ) + if manifest_profile_codegen_backends or manifest_profile_rustflag_authority: + raise AssertionError( + "Cargo Rust tool/target execution override requires an explicit Browser Session provenance contract: " + f"Cargo.toml profile_codegen_backends={manifest_profile_codegen_backends} " + f"profile_rustflag_authority={manifest_profile_rustflag_authority}" + ) + + root_resolved = root.resolve() + config_paths: set[pathlib.Path] = set() + for pattern in (".cargo/config.toml", ".cargo/config"): + config_paths.update(root.rglob(pattern)) + + for config_path in sorted(config_paths): + resolved = config_path.resolve() + try: + resolved.relative_to(root_resolved) + except ValueError as exc: + raise AssertionError( + f"Cargo compiler config escapes repository review root: {config_path.relative_to(root).as_posix()}" + ) from exc + if not resolved.is_file(): + raise AssertionError( + f"Cargo compiler config is missing: {config_path.relative_to(root).as_posix()}" + ) + + parsed = tomllib.loads(resolved.read_text(encoding="utf-8")) + included_configs = parsed.get("include") + include_configured = included_configs is not None + environment = parsed.get("env") + environment_configured = ( + sorted(str(name) for name in environment) if isinstance(environment, dict) else [] + ) + unstable_configured = _configured_unstable_toolchain_inputs(parsed.get("unstable")) + profile_codegen_backends = _configured_profile_codegen_backends(parsed.get("profile")) + profile_rustflag_authority = _configured_profile_rustflag_authority(parsed.get("profile")) + host_configured = _configured_host_execution_authority(parsed.get("host")) + + build = parsed.get("build") + build_configured = ( + sorted(COMPILER_EXECUTION_KEYS.intersection(build)) if isinstance(build, dict) else [] + ) + if isinstance(build, dict): + build_configured.extend( + f"target:custom target specification:{target_spec}" + for target_spec in _configured_custom_target_specs(build.get("target")) + ) + if _flags_select_codegen_backend(build.get("rustflags")): + build_configured.append("rustflags:codegen backend") + if _flags_select_linker(build.get("rustflags")): + build_configured.append("rustflags:codegen linker") + if _flags_extend_external_link_inputs(build.get("rustflags")): + build_configured.append("rustflags:external link input") + if _flags_select_ambient_host_cpu(build.get("rustflags")): + build_configured.append("rustflags:ambient host cpu") + if _flags_select_codegen_backend(build.get("rustdocflags")): + build_configured.append("rustdocflags:codegen backend") + if _flags_select_linker(build.get("rustdocflags")): + build_configured.append("rustdocflags:codegen linker") + if _flags_extend_external_link_inputs(build.get("rustdocflags")): + build_configured.append("rustdocflags:external link input") + if _flags_select_ambient_host_cpu(build.get("rustdocflags")): + build_configured.append("rustdocflags:ambient host cpu") + if _flags_select_rustdoc_test_execution(build.get("rustdocflags")): + build_configured.append("rustdocflags:doctest execution") + if _flags_select_rustdoc_documentation_input(build.get("rustdocflags")): + build_configured.append("rustdocflags:documentation input") + if _flags_select_rustdoc_doctest_compiler_authority(build.get("rustdocflags")): + build_configured.append("rustdocflags:doctest compiler authority") + + target_configured: dict[str, list[str]] = {} + target = parsed.get("target") + if isinstance(target, dict): + for target_name, settings in target.items(): + if not isinstance(settings, dict): + continue + configured = sorted(TARGET_EXECUTION_KEYS.intersection(settings)) + linked_build_overrides = [] + if not str(target_name).startswith("cfg("): + linked_build_overrides = sorted( + str(name) for name, value in settings.items() if isinstance(value, dict) + ) + configured.extend( + f"links build-script override:{name}" for name in linked_build_overrides + ) + if _flags_select_codegen_backend(settings.get("rustflags")): + configured.append("rustflags:codegen backend") + if _flags_select_linker(settings.get("rustflags")): + configured.append("rustflags:codegen linker") + if _flags_extend_external_link_inputs(settings.get("rustflags")): + configured.append("rustflags:external link input") + if _flags_select_ambient_host_cpu(settings.get("rustflags")): + configured.append("rustflags:ambient host cpu") + if _flags_select_codegen_backend(settings.get("rustdocflags")): + configured.append("rustdocflags:codegen backend") + if _flags_select_linker(settings.get("rustdocflags")): + configured.append("rustdocflags:codegen linker") + if _flags_extend_external_link_inputs(settings.get("rustdocflags")): + configured.append("rustdocflags:external link input") + if _flags_select_ambient_host_cpu(settings.get("rustdocflags")): + configured.append("rustdocflags:ambient host cpu") + if _flags_select_rustdoc_test_execution(settings.get("rustdocflags")): + configured.append("rustdocflags:doctest execution") + if _flags_select_rustdoc_documentation_input(settings.get("rustdocflags")): + configured.append("rustdocflags:documentation input") + if _flags_select_rustdoc_doctest_compiler_authority(settings.get("rustdocflags")): + configured.append("rustdocflags:doctest compiler authority") + if configured: + target_configured[str(target_name)] = configured + + if ( + build_configured + or target_configured + or host_configured + or environment_configured + or include_configured + or unstable_configured + or profile_codegen_backends + or profile_rustflag_authority + ): + relative = config_path.relative_to(root).as_posix() + raise AssertionError( + "Cargo Rust tool/target execution override requires an explicit Browser Session provenance contract: " + f"{relative} build_keys={build_configured} target_keys={target_configured} " + f"host_keys={host_configured} env_keys={environment_configured} include={include_configured} " + f"unstable_keys={unstable_configured} profile_codegen_backends={profile_codegen_backends} " + f"profile_rustflag_authority={profile_rustflag_authority}" + ) + + +class BrowserSessionCargoCompilerAuthorityContractTests(unittest.TestCase): + """Keep Git-owned Cargo executable selection inside the reviewed Browser Session TCB.""" + + def _workspace_with_config( + self, + config_text: str, + *, + config_name: str = "config.toml", + nested: bool = False, + ) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + config_root = adapter if nested else root + cargo = config_root / ".cargo" + cargo.mkdir() + (cargo / config_name).write_text(config_text, encoding="utf-8") + return root + + def _assert_compiler_override_fails_closed( + self, + config_text: str, + *, + config_name: str = "config.toml", + nested: bool = False, + ) -> None: + root = self._workspace_with_config( + config_text, + config_name=config_name, + nested=nested, + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_current_repository_has_no_unmodeled_cargo_compiler_execution_override(self) -> None: + _assert_no_repository_cargo_compiler_execution_overrides(ROOT) + + def test_repository_rustc_wrapper_fails_closed(self) -> None: + self._assert_compiler_override_fails_closed( + '[build]\nrustc-wrapper = "tools/review-bypass-wrapper"\n' + ) + + def test_repository_rustc_workspace_wrapper_fails_closed(self) -> None: + self._assert_compiler_override_fails_closed( + '[build]\nrustc-workspace-wrapper = "tools/workspace-wrapper"\n' + ) + + def test_repository_custom_rustc_fails_closed(self) -> None: + self._assert_compiler_override_fails_closed( + '[build]\nrustc = "tools/custom-rustc"\n' + ) + + def test_repository_custom_rustdoc_fails_closed(self) -> None: + self._assert_compiler_override_fails_closed( + '[build]\nrustdoc = "tools/review-bypass-rustdoc"\n' + ) + + def test_nested_extensionless_cargo_config_compiler_override_fails_closed(self) -> None: + self._assert_compiler_override_fails_closed( + '[build]\nrustc-wrapper = "tools/nested-wrapper"\n', + config_name="config", + nested=True, + ) + + def test_repository_target_linker_fails_closed(self) -> None: + root = self._workspace_with_config( + '[target.x86_64-unknown-linux-gnu]\nlinker = "tools/review-bypass-linker"\n' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_target_runner_fails_closed(self) -> None: + root = self._workspace_with_config( + "[target.'cfg(unix)']\nrunner = \"tools/review-bypass-runner\"\n" + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_build_rustflags_linker_override_fails_closed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustflags = ["-C", "linker=tools/review-bypass-linker"]\n' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_target_rustflags_linker_override_fails_closed(self) -> None: + root = self._workspace_with_config( + "[target.'cfg(unix)']\nrustflags = \"-C linker=tools/review-bypass-linker\"\n" + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_compact_rustflags_linker_override_fails_closed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustflags = ["-Clinker=tools/review-bypass-linker"]\n' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_long_build_rustflags_linker_override_fails_closed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustflags = ["--codegen", "linker=tools/review-bypass-linker"]\n' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_long_target_rustflags_linker_override_fails_closed(self) -> None: + root = self._workspace_with_config( + "[target.'cfg(unix)']\nrustflags = \"--codegen=linker=tools/review-bypass-linker\"\n" + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_build_rustdocflags_linker_override_fails_closed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustdocflags = ["-C", "linker=tools/review-bypass-linker"]\n' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_target_rustdocflags_linker_override_fails_closed(self) -> None: + root = self._workspace_with_config( + "[target.'cfg(unix)']\nrustdocflags = \"-Clinker=tools/review-bypass-linker\"\n" + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_long_build_rustdocflags_linker_override_fails_closed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustdocflags = ["--codegen", "linker=tools/review-bypass-linker"]\n' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_long_target_rustdocflags_linker_override_fails_closed(self) -> None: + root = self._workspace_with_config( + "[target.'cfg(unix)']\nrustdocflags = \"--codegen=linker=tools/review-bypass-linker\"\n" + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_forwarded_linker_response_file_fails_closed(self) -> None: + for forwarded in ( + "-Wl,@tools/review-bypass-linker.rsp", + "--for-linker=@tools/review-bypass-linker.rsp", + "-Xlinker @tools/review-bypass-linker.rsp", + ): + with self.subTest(forwarded=forwarded): + root = self._workspace_with_config( + f'[build]\nrustflags = ["-C", "link-args={forwarded}"]\n' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_non_linker_selecting_link_arg_remains_allowed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--as-needed"]\n' + ) + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_linker_forwarded_bsymbolic_remains_allowed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-Bsymbolic"]\n' + ) + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_unrelated_rustflags_remain_allowed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustflags = ["-C", "opt-level=2", "--cfg", "originweave_reviewed"]\n' + ) + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_unrelated_rustdocflags_remain_allowed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustdocflags = ["--document-private-items", "--cfg", "docsrs"]\n' + ) + _assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_unrelated_build_configuration_remains_allowed(self) -> None: + root = self._workspace_with_config('[build]\njobs = 2\nincremental = false\n') + _assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_browser_session_cargo_environment_authority_contract.py b/tests/test_browser_session_cargo_environment_authority_contract.py new file mode 100644 index 000000000..38ae9e421 --- /dev/null +++ b/tests/test_browser_session_cargo_environment_authority_contract.py @@ -0,0 +1,57 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionCargoEnvironmentAuthorityContractTests(unittest.TestCase): + """Keep Git-owned Cargo compiler environment inside reviewed provenance.""" + + def test_repository_cargo_environment_inputs_fail_closed(self) -> None: + hostile_configs = ( + '[env]\nORIGINWEAVE_BUILD_ID = "unreviewed"\n', + '[env]\nORIGINWEAVE_BUILD_ID = { value = "unreviewed", force = true }\n', + '[env]\nORIGINWEAVE_TOOL_ROOT = { value = "tools", relative = true, force = true }\n', + ) + for config_text in hostile_configs: + with self.subTest(config_text=config_text): + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_empty_cargo_environment_table_remains_allowed(self) -> None: + root = self._workspace_with_config("[env]\n") + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = self.enterContext(tempfile.TemporaryDirectory()) + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_cargo_host_config_authority_contract.py b/tests/test_browser_session_cargo_host_config_authority_contract.py new file mode 100644 index 000000000..b05d7f317 --- /dev/null +++ b/tests/test_browser_session_cargo_host_config_authority_contract.py @@ -0,0 +1,109 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location( + "browser_session_cargo_compiler_authority_contract", + AUTHORITY_TEST, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionCargoHostConfigAuthorityContractTests(unittest.TestCase): + """Keep nightly Cargo host-target execution inside the canonical compiler-authority owner.""" + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def _assert_host_authority_fails_closed(self, config_text: str) -> None: + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_host_linker_fails_closed(self) -> None: + self._assert_host_authority_fails_closed( + '[host]\nlinker = "tools/review-bypass-host-linker"\n' + ) + + def test_repository_host_arch_runner_fails_closed(self) -> None: + self._assert_host_authority_fails_closed( + '[host.x86_64-unknown-linux-gnu]\nrunner = "tools/review-bypass-host-runner"\n' + ) + + def test_repository_host_rustflags_external_input_fails_closed(self) -> None: + self._assert_host_authority_fails_closed( + '[host]\nrustflags = ["-C", "link-arg=-Wl,--library=review_bypass"]\n' + ) + + def test_repository_host_rustdocflags_external_input_fails_closed(self) -> None: + self._assert_host_authority_fails_closed( + '[host.x86_64-unknown-linux-gnu]\n' + 'rustdocflags = ["--extern=review_bypass=tools/libreview_bypass.rlib"]\n' + ) + + def test_repository_host_links_build_script_override_fails_closed(self) -> None: + self._assert_host_authority_fails_closed( + '[host.x86_64-unknown-linux-gnu.review_bypass]\n' + 'rustc-link-search = ["tools/review-bypass-native"]\n' + ) + + def test_repository_generic_host_links_build_script_override_fails_closed(self) -> None: + self._assert_host_authority_fails_closed( + '[host.review_bypass]\n' + 'rustc-link-search = ["tools/review-bypass-native"]\n' + ) + + def test_repository_empty_host_links_build_script_override_fails_closed(self) -> None: + self._assert_host_authority_fails_closed('[host.review_bypass]\n') + + def test_repository_empty_host_triple_links_build_script_override_fails_closed(self) -> None: + self._assert_host_authority_fails_closed( + '[host.x86_64-unknown-linux-gnu.review_bypass]\n' + ) + + def test_unrelated_host_rustflags_remain_allowed(self) -> None: + root = self._workspace_with_config( + '[host]\nrustflags = ["-C", "opt-level=2"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_unrelated_host_triple_rustflags_remain_allowed(self) -> None: + root = self._workspace_with_config( + '[host.x86_64-unknown-linux-gnu]\nrustflags = ["-C", "opt-level=2"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_unrelated_host_rustdocflags_remain_allowed(self) -> None: + root = self._workspace_with_config( + '[host]\nrustdocflags = ["--document-private-items"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_cargo_include_authority_contract.py b/tests/test_browser_session_cargo_include_authority_contract.py new file mode 100644 index 000000000..a60e23433 --- /dev/null +++ b/tests/test_browser_session_cargo_include_authority_contract.py @@ -0,0 +1,67 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionCargoIncludeAuthorityContractTests(unittest.TestCase): + """Keep Cargo-included configuration inside reviewed repository provenance.""" + + def test_repository_cargo_include_fails_closed(self) -> None: + include_forms = ( + 'include = ["../.config/review-bypass.toml"]\n', + 'include = [{ path = "../.config/review-bypass.toml" }]\n', + 'include = [{ path = "../.config/review-bypass.toml", optional = true }]\n', + ) + for config_text in include_forms: + with self.subTest(config_text=config_text): + root = self._workspace_with_included_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_config_without_include_remains_allowed(self) -> None: + root = self._workspace_with_config('[build]\njobs = 2\n') + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def _workspace_with_included_config(self, config_text: str) -> pathlib.Path: + root = self._workspace_with_config(config_text) + extra = root / ".config" + extra.mkdir() + (extra / "review-bypass.toml").write_text( + '[env]\nORIGINWEAVE_BUILD_ID = "included-unreviewed"\n', + encoding="utf-8", + ) + return root + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = self.enterContext(tempfile.TemporaryDirectory()) + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_cargo_links_override_contract.py b/tests/test_browser_session_cargo_links_override_contract.py new file mode 100644 index 000000000..13b0dd8ba --- /dev/null +++ b/tests/test_browser_session_cargo_links_override_contract.py @@ -0,0 +1,61 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionCargoLinksOverrideContractTests(unittest.TestCase): + """Keep Cargo links build-script overrides inside reviewed compiler provenance.""" + + def test_target_links_build_script_override_fails_closed(self) -> None: + hostile_configs = ( + '[target.x86_64-unknown-linux-gnu.review_bypass]\nrustc-link-lib = ["review_bypass"]\n', + '[target.x86_64-unknown-linux-gnu.review_bypass]\nrustc-link-search = ["tools/native"]\n', + '[target.x86_64-unknown-linux-gnu.review_bypass]\nrustc-cfg = ["originweave_review_bypass"]\n', + '[target.x86_64-unknown-linux-gnu.review_bypass]\nrustc-env = { ORIGINWEAVE_BUILD_ID = "unreviewed" }\n', + '[target.x86_64-unknown-linux-gnu.review_bypass]\nrustc-cdylib-link-arg = ["tools/review-bypass.o"]\n', + ) + for config_text in hostile_configs: + with self.subTest(config_text=config_text): + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_ordinary_target_table_without_links_override_remains_allowed(self) -> None: + root = self._workspace_with_config( + '[target.x86_64-unknown-linux-gnu]\nrustflags = ["--cfg", "originweave_reviewed"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = self.enterContext(tempfile.TemporaryDirectory()) + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_cfg_target_links_override_authority_contract.py b/tests/test_browser_session_cfg_target_links_override_authority_contract.py new file mode 100644 index 000000000..c747985c9 --- /dev/null +++ b/tests/test_browser_session_cfg_target_links_override_authority_contract.py @@ -0,0 +1,60 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location( + "browser_session_cargo_compiler_authority_contract", + AUTHORITY_TEST, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionCfgTargetLinksOverrideAuthorityContractTests(unittest.TestCase): + """Keep Cargo cfg-target warnings separate from tuple links override authority.""" + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def test_cfg_target_unknown_nested_table_is_not_links_override_authority(self) -> None: + root = self._workspace_with_config( + "[target.'cfg(unix)'.review_bypass]\n" + 'rustc-link-search = ["tools/not-a-target-links-override"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_tuple_target_links_override_remains_authority(self) -> None: + root = self._workspace_with_config( + "[target.x86_64-unknown-linux-gnu.review_bypass]\n" + 'rustc-link-search = ["tools/review-bypass-native"]\n' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_codegen_backend_authority_contract.py b/tests/test_browser_session_codegen_backend_authority_contract.py new file mode 100644 index 000000000..b9f78259f --- /dev/null +++ b/tests/test_browser_session_codegen_backend_authority_contract.py @@ -0,0 +1,82 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionCodegenBackendAuthorityContractTests(unittest.TestCase): + """Keep repository-selected rustc code generation backends outside the Browser Session TCB.""" + + def _workspace_with_config( + self, + config_text: str = "", + *, + root_profile_text: str = "", + ) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n' + root_profile_text, + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + if config_text: + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def _assert_fails_closed( + self, + config_text: str = "", + *, + root_profile_text: str = "", + ) -> None: + root = self._workspace_with_config(config_text, root_profile_text=root_profile_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_rustflags_codegen_backend_path_fails_closed(self) -> None: + for config_text in ( + '[build]\nrustflags = ["-Zcodegen-backend=tools/review-bypass-backend.so"]\n', + '[build]\nrustflags = ["-Z", "codegen-backend=tools/review-bypass-backend.so"]\n', + "[target.'cfg(unix)']\nrustflags = [\"-Zcodegen-backend=tools/review-bypass-backend.so\"]\n", + ): + with self.subTest(config_text=config_text): + self._assert_fails_closed(config_text) + + def test_repository_config_profile_codegen_backend_fails_closed(self) -> None: + self._assert_fails_closed( + '[unstable]\ncodegen-backend = true\n\n' + '[profile.dev.package.adapter]\ncodegen-backend = "cranelift"\n' + ) + + def test_repository_manifest_profile_codegen_backend_fails_closed(self) -> None: + self._assert_fails_closed( + root_profile_text='\n[profile.dev]\ncodegen-backend = "cranelift"\n' + ) + + def test_unrelated_profile_setting_remains_allowed(self) -> None: + root = self._workspace_with_config(root_profile_text='\n[profile.dev]\nopt-level = 1\n') + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_custom_target_mod_lexical_contract.py b/tests/test_browser_session_custom_target_mod_lexical_contract.py new file mode 100644 index 000000000..d2e5a4fd3 --- /dev/null +++ b/tests/test_browser_session_custom_target_mod_lexical_contract.py @@ -0,0 +1,102 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +INDIRECTION_TEST = ROOT / "tests/test_browser_session_rust_source_indirection_contract.py" + +spec = importlib.util.spec_from_file_location( + "browser_session_rust_source_indirection_contract", + INDIRECTION_TEST, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Rust source-indirection contract") +indirection = importlib.util.module_from_spec(spec) +spec.loader.exec_module(indirection) + + +class BrowserSessionCustomTargetModLexicalContractTests(unittest.TestCase): + """Keep custom-target module detection lexical instead of raw-text based.""" + + def _custom_target_workspace(self, source_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "runtime").mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n' + '[lib]\npath = "runtime/lifecycle_adapter.rs"\n', + encoding="utf-8", + ) + (adapter / "runtime/lifecycle_adapter.rs").write_text(source_text, encoding="utf-8") + return root + + def test_line_comment_mod_text_is_lexical_data(self) -> None: + root = self._custom_target_workspace( + '// mod hidden;\npub fn lifecycle_adapter_surface() {}\n' + ) + + indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_nested_block_comment_mod_text_is_lexical_data(self) -> None: + root = self._custom_target_workspace( + '/* outer /* mod hidden; */ still comment */\n' + 'pub fn lifecycle_adapter_surface() {}\n' + ) + + indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_ordinary_string_mod_text_is_lexical_data(self) -> None: + root = self._custom_target_workspace( + 'pub const NOTE: &str = "mod hidden;";\npub fn lifecycle_adapter_surface() {}\n' + ) + + indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_raw_string_mod_text_is_lexical_data(self) -> None: + root = self._custom_target_workspace( + 'pub const NOTE: &str = r#"mod hidden;"#;\npub fn lifecycle_adapter_surface() {}\n' + ) + + indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_character_literal_does_not_hide_following_real_mod(self) -> None: + root = self._custom_target_workspace( + "pub const MARKER: char = 'm';\nmod helper;\n" + ) + + with self.assertRaisesRegex(AssertionError, "Rust module source indirection"): + indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_xid_continue_after_mod_is_identifier_data_not_keyword(self) -> None: + root = self._custom_target_workspace( + "pub fn mod\u0301() {}\npub fn lifecycle_adapter_surface() {}\n" + ) + + indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_xid_continue_before_mod_keeps_one_identifier_token(self) -> None: + root = self._custom_target_workspace( + "pub fn a\u0301mod() {}\npub fn lifecycle_adapter_surface() {}\n" + ) + + indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_non_ascii_rust_whitespace_still_separates_real_mod_keyword(self) -> None: + root = self._custom_target_workspace( + "mod\u200ehelper;\npub fn lifecycle_adapter_surface() {}\n" + ) + + with self.assertRaisesRegex(AssertionError, "Rust module source indirection"): + indirection._assert_no_unmodeled_rust_source_indirection(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_custom_target_source_contract.py b/tests/test_browser_session_custom_target_source_contract.py new file mode 100644 index 000000000..68957557d --- /dev/null +++ b/tests/test_browser_session_custom_target_source_contract.py @@ -0,0 +1,53 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +IMPLICIT_WORKSPACE_CONTRACT = ROOT / "tests/test_browser_session_implicit_workspace_member_contract.py" + +spec = importlib.util.spec_from_file_location( + "browser_session_implicit_workspace_member_contract", + IMPLICIT_WORKSPACE_CONTRACT, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session implicit-workspace contract") +implicit_workspace = importlib.util.module_from_spec(spec) +spec.loader.exec_module(implicit_workspace) + + +class BrowserSessionCustomTargetSourceContractTests(unittest.TestCase): + """Keep non-standard Cargo production target paths inside the TCB review surface.""" + + def test_custom_lib_and_bin_paths_cannot_escape_production_source_review(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["plugins/browser-adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + + adapter = root / "plugins/browser-adapter" + adapter.mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "browser-adapter"\nversion = "0.1.0"\nedition = "2024"\n' + '[lib]\npath = "runtime/lifecycle_adapter.rs"\n' + '[[bin]]\nname = "browser-adapter-cli"\npath = "command/adapter_cli.rs"\n', + encoding="utf-8", + ) + + library_source = adapter / "runtime/lifecycle_adapter.rs" + library_source.parent.mkdir() + library_source.write_text("pub fn lifecycle_adapter() {}\n", encoding="utf-8") + binary_source = adapter / "command/adapter_cli.rs" + binary_source.parent.mkdir() + binary_source.write_text("fn main() {}\n", encoding="utf-8") + + production_sources = implicit_workspace._production_sources(root) + self.assertIn(library_source, production_sources) + self.assertIn(binary_source, production_sources) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_custom_target_spec_authority_contract.py b/tests/test_browser_session_custom_target_spec_authority_contract.py new file mode 100644 index 000000000..dd29e6468 --- /dev/null +++ b/tests/test_browser_session_custom_target_spec_authority_contract.py @@ -0,0 +1,83 @@ +import importlib.util +import json +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +COMPILER_AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location( + "browser_session_cargo_compiler_authority", + COMPILER_AUTHORITY_TEST, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +compiler_authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(compiler_authority) + + +class BrowserSessionCustomTargetSpecAuthorityContractTests(unittest.TestCase): + """Keep repository-selected rustc target specifications inside reviewed provenance.""" + + def _workspace_with_build_target(self, target_value: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text( + f"[unstable]\njson-target-spec = true\n\n[build]\ntarget = {target_value}\n", + encoding="utf-8", + ) + targets = root / "targets" + targets.mkdir() + (targets / "review-bypass.json").write_text( + json.dumps( + { + "llvm-target": "x86_64-unknown-linux-gnu", + "arch": "x86_64", + "target-pointer-width": "64", + "data-layout": "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128", + "linker": "tools/review-bypass-linker", + } + ), + encoding="utf-8", + ) + return root + + def test_build_target_custom_json_fails_closed(self) -> None: + root = self._workspace_with_build_target('"targets/review-bypass.json"') + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + compiler_authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_target_array_with_custom_json_fails_closed(self) -> None: + root = self._workspace_with_build_target( + '["x86_64-unknown-linux-gnu", "targets/review-bypass.json"]' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + compiler_authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_builtin_target_triple_remains_allowed(self) -> None: + root = self._workspace_with_build_target('"x86_64-unknown-linux-gnu"') + compiler_authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_host_tuple_remains_allowed(self) -> None: + root = self._workspace_with_build_target('"host-tuple"') + compiler_authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_dtlto_execution_authority_contract.py b/tests/test_browser_session_dtlto_execution_authority_contract.py new file mode 100644 index 000000000..6a5eff800 --- /dev/null +++ b/tests/test_browser_session_dtlto_execution_authority_contract.py @@ -0,0 +1,79 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionDistributedThinLtoExecutionAuthorityContractTests(unittest.TestCase): + """Keep LLD DTLTO distributor/compiler executables outside reviewed Browser Session builds.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_dtlto_distributor_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--thinlto-distributor=tools/review-bypass-distributor"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_dtlto_remote_compiler_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=-Wl,--thinlto-remote-compiler=tools/review-bypass-compiler\"]\n", + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_dtlto_distributor_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-arg=-Wl,--thinlto-distributor=tools/review-bypass-distributor"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_forwarded_dtlto_remote_compiler_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,--thinlto-remote-compiler=tools/review-bypass-compiler"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_non_executable_thinlto_job_count_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--thinlto-jobs=2"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_extern_input_contract.py b/tests/test_browser_session_extern_input_contract.py new file mode 100644 index 000000000..b3997e654 --- /dev/null +++ b/tests/test_browser_session_extern_input_contract.py @@ -0,0 +1,71 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +COMPILER_AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location( + "browser_session_cargo_compiler_authority_contract", + COMPILER_AUTHORITY_TEST, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionExternInputContractTests(unittest.TestCase): + """Keep Git-owned explicit external-crate inputs inside the reviewed Browser Session TCB.""" + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def _assert_extern_input_fails_closed(self, config_text: str) -> None: + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_split_extern_path_fails_closed(self) -> None: + self._assert_extern_input_fails_closed( + '[build]\nrustflags = ["--extern", "review_bypass=tools/libreview_bypass.rlib"]\n' + ) + + def test_target_rustflags_equals_extern_path_fails_closed(self) -> None: + self._assert_extern_input_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"--extern=review_bypass=tools/libreview_bypass.so\"]\n" + ) + + def test_build_rustflags_pathless_extern_fails_closed(self) -> None: + self._assert_extern_input_fails_closed( + '[build]\nrustflags = ["--extern", "review_bypass"]\n' + ) + + def test_unrelated_check_cfg_remains_allowed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustflags = ["--check-cfg", "cfg(originweave_reviewed)"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_host_cpu_codegen_authority_contract.py b/tests/test_browser_session_host_cpu_codegen_authority_contract.py new file mode 100644 index 000000000..893e5fc34 --- /dev/null +++ b/tests/test_browser_session_host_cpu_codegen_authority_contract.py @@ -0,0 +1,91 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionHostCpuCodegenAuthorityContractTests(unittest.TestCase): + """Keep repository-selected host-CPU-dependent code generation out of reproducible builds.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_target_cpu_native_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "target-cpu=native"]\n', + "rustflags:ambient host cpu", + ) + + def test_target_rustflags_compact_target_cpu_native_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-Ctarget-cpu=native\"]\n", + "rustflags:ambient host cpu", + ) + + def test_profile_rustflags_target_cpu_native_fails_closed(self) -> None: + self._assert_fails_closed( + '[unstable]\nprofile-rustflags = true\n\n[profile.release]\nrustflags = ["--codegen=target-cpu=native"]\n', + "profile.release.rustflags:ambient host cpu", + ) + + def test_build_rustdocflags_target_cpu_native_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--codegen=target-cpu=native"]\n', + "rustdocflags:ambient host cpu", + ) + + def test_doctest_build_arg_target_cpu_native_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=target-cpu=native"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_build_rustflags_tune_cpu_native_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-Z", "tune-cpu=native"]\n', + "rustflags:ambient host cpu", + ) + + def test_explicit_target_cpu_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "target-cpu=x86-64-v3"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_implicit_workspace_member_contract.py b/tests/test_browser_session_implicit_workspace_member_contract.py new file mode 100644 index 000000000..143160117 --- /dev/null +++ b/tests/test_browser_session_implicit_workspace_member_contract.py @@ -0,0 +1,231 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +BOUNDARY_TEST = ROOT / "tests/test_browser_session_trusted_adapter_boundary.py" + +spec = importlib.util.spec_from_file_location("browser_session_trusted_adapter_boundary", BOUNDARY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session trusted-adapter boundary contract") +boundary = importlib.util.module_from_spec(spec) +spec.loader.exec_module(boundary) + +# The trusted-adapter boundary is the single writer for production Cargo topology. +# These aliases keep the hostile topology fixtures on that exact scanner rather than +# maintaining a second implementation that can drift away from the security gate. +_production_package_manifests = boundary._production_package_manifests +_production_sources = boundary._workspace_production_sources + + +class BrowserSessionImplicitWorkspaceMemberContractTests(unittest.TestCase): + """Cover Cargo's automatic in-workspace path-dependency membership semantics.""" + + def test_in_workspace_path_dependency_cannot_escape_review_surface(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["app"]\nresolver = "3"\n', + encoding="utf-8", + ) + + app = root / "app" + app.mkdir() + (app / "Cargo.toml").write_text( + '[package]\nname = "app"\nversion = "0.1.0"\nedition = "2024"\n' + '[dependencies]\nbrowser-adapter = { path = "../plugins/browser-adapter" }\n', + encoding="utf-8", + ) + (app / "src").mkdir() + (app / "src/lib.rs").write_text("pub fn app() {}\n", encoding="utf-8") + + adapter = root / "plugins/browser-adapter" + adapter.mkdir(parents=True) + adapter_manifest = adapter / "Cargo.toml" + adapter_manifest.write_text( + '[package]\nname = "browser-adapter"\nversion = "0.1.0"\nedition = "2024"\n' + '[dependencies]\noriginweave-browser-session = { path = "../../crates/originweave-browser-session" }\n', + encoding="utf-8", + ) + adapter_source = adapter / "src/lib.rs" + adapter_source.parent.mkdir() + adapter_source.write_text( + "use originweave_browser_session::DisposableContextPort;\n", + encoding="utf-8", + ) + + self.assertIn(adapter_manifest, _production_package_manifests(root)) + self.assertIn(adapter_source, _production_sources(root)) + + def test_recursive_path_dependency_enters_canonical_binding_review_surface(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["app"]\nresolver = "3"\n', + encoding="utf-8", + ) + + app = root / "app" + app.mkdir() + (app / "Cargo.toml").write_text( + '[package]\nname = "app"\nversion = "0.1.0"\nedition = "2024"\n' + '[dependencies]\nbrowser-adapter = { path = "../plugins/browser-adapter" }\n', + encoding="utf-8", + ) + (app / "src").mkdir() + (app / "src/lib.rs").write_text("pub fn app() {}\n", encoding="utf-8") + + adapter = root / "plugins/browser-adapter" + adapter.mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "browser-adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + adapter_source = adapter / "src/lib.rs" + adapter_source.parent.mkdir() + adapter_source.write_text( + "pub fn attach(session: &mut Session, port: Port) { session.bind_lifecycle_port(port); }\n", + encoding="utf-8", + ) + + self.assertIn(adapter_source, _production_sources(root)) + self.assertTrue(boundary._has_lifecycle_binding(adapter_source.read_text(encoding="utf-8"))) + self.assertIn( + adapter_source, + boundary._workspace_production_sources(root), + "canonical lifecycle-binding review must include recursive in-repository path dependencies", + ) + + def test_external_production_path_dependency_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = pathlib.Path(directory) + root = parent / "repo" + root.mkdir() + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["app"]\nresolver = "3"\n', + encoding="utf-8", + ) + + app = root / "app" + app.mkdir() + (app / "Cargo.toml").write_text( + '[package]\nname = "app"\nversion = "0.1.0"\nedition = "2024"\n' + '[dependencies]\nexternal-adapter = { path = "../../external-adapter" }\n', + encoding="utf-8", + ) + (app / "src").mkdir() + (app / "src/lib.rs").write_text("pub fn app() {}\n", encoding="utf-8") + + external = parent / "external-adapter" + external.mkdir() + (external / "Cargo.toml").write_text( + '[package]\nname = "external-adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (external / "src").mkdir() + (external / "src/lib.rs").write_text("pub fn external() {}\n", encoding="utf-8") + + with self.assertRaisesRegex( + AssertionError, + "production Cargo path dependency escapes repository review root", + ): + _production_package_manifests(root) + + def test_missing_production_path_dependency_manifest_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["app"]\nresolver = "3"\n', + encoding="utf-8", + ) + + app = root / "app" + app.mkdir() + (app / "Cargo.toml").write_text( + '[package]\nname = "app"\nversion = "0.1.0"\nedition = "2024"\n' + '[dependencies]\nmissing-adapter = { path = "../plugins/missing-adapter" }\n', + encoding="utf-8", + ) + (app / "src").mkdir() + (app / "src/lib.rs").write_text("pub fn app() {}\n", encoding="utf-8") + + with self.assertRaisesRegex( + AssertionError, + "production Cargo path dependency manifest is missing", + ): + _production_package_manifests(root) + + def test_workspace_inherited_path_dependency_cannot_escape_review_surface(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["app"]\nresolver = "3"\n' + '[workspace.dependencies]\nbrowser_adapter = { path = "plugins/browser-adapter" }\n', + encoding="utf-8", + ) + + app = root / "app" + app.mkdir() + (app / "Cargo.toml").write_text( + '[package]\nname = "app"\nversion = "0.1.0"\nedition = "2024"\n' + '[target.\'cfg(unix)\'.dependencies]\nbrowser_adapter = { workspace = true }\n', + encoding="utf-8", + ) + (app / "src").mkdir() + (app / "src/lib.rs").write_text("pub fn app() {}\n", encoding="utf-8") + + adapter = root / "plugins/browser-adapter" + adapter.mkdir(parents=True) + adapter_manifest = adapter / "Cargo.toml" + adapter_manifest.write_text( + '[package]\nname = "browser-adapter"\nversion = "0.1.0"\nedition = "2024"\n' + '[dependencies]\noriginweave-browser-session = { path = "../../crates/originweave-browser-session" }\n', + encoding="utf-8", + ) + adapter_source = adapter / "src/lib.rs" + adapter_source.parent.mkdir() + adapter_source.write_text( + "use originweave_browser_session::DisposableContextPort;\n", + encoding="utf-8", + ) + + self.assertIn(adapter_manifest, _production_package_manifests(root)) + self.assertIn(adapter_source, _production_sources(root)) + + def test_recursive_local_path_dependencies_obey_existing_tcb_allowlists(self) -> None: + workspace_text = (ROOT / "Cargo.toml").read_text(encoding="utf-8") + + discovered_port_references = set() + for path in _production_sources(ROOT): + relative = path.relative_to(ROOT).as_posix() + if relative.startswith(boundary.BROWSER_SESSION_SOURCE_ROOT): + continue + if boundary._has_port_reference(path.read_text(encoding="utf-8")): + discovered_port_references.add(relative) + + discovered_dependencies = set() + for path in _production_package_manifests(ROOT): + if path == boundary.BROWSER_SESSION_CARGO: + continue + if boundary._manifest_links_browser_session( + path.read_text(encoding="utf-8"), + workspace_text, + ): + discovered_dependencies.add(path.relative_to(ROOT).as_posix()) + + self.assertEqual( + boundary.APPROVED_PRODUCTION_PORT_REFERENCES, + discovered_port_references, + "recursive local path dependencies must not add unreviewed lifecycle-port source", + ) + self.assertEqual( + boundary.APPROVED_BROWSER_SESSION_DEPENDENCIES, + discovered_dependencies, + "recursive local path dependencies must not link Browser Session outside the reviewed allowlist", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_include_comment_trivia_contract.py b/tests/test_browser_session_include_comment_trivia_contract.py new file mode 100644 index 000000000..c12e1a72f --- /dev/null +++ b/tests/test_browser_session_include_comment_trivia_contract.py @@ -0,0 +1,90 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE_CONTRACT = ROOT / "tests/test_browser_session_rust_source_indirection_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_rust_source_indirection", SOURCE_CONTRACT) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Rust source-indirection contract") +source_contract = importlib.util.module_from_spec(spec) +spec.loader.exec_module(source_contract) + + +class BrowserSessionIncludeCommentTriviaContractTests(unittest.TestCase): + """Keep Rust lexical trivia from bypassing include! source-provenance review.""" + + def _workspace_with_source(self, source_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (adapter / "src/lib.rs").write_text(source_text, encoding="utf-8") + (adapter / "generated_adapter.rs").write_text( + "pub fn generated_adapter_surface() {}\n", + encoding="utf-8", + ) + return root + + def _assert_include_trivia_fails_closed(self, source_text: str) -> None: + root = self._workspace_with_source(source_text) + with self.assertRaisesRegex(AssertionError, "Rust include! source indirection"): + source_contract._assert_no_unmodeled_rust_source_indirection(root) + + def test_block_comment_between_include_and_bang_fails_closed(self) -> None: + self._assert_include_trivia_fails_closed( + 'include /* provenance gap */ ! ("../generated_adapter.rs");\n' + ) + + def test_block_comment_between_bang_and_delimiter_fails_closed(self) -> None: + self._assert_include_trivia_fails_closed( + 'include! /* provenance gap */ ("../generated_adapter.rs");\n' + ) + + def test_line_comment_between_include_and_bang_fails_closed(self) -> None: + self._assert_include_trivia_fails_closed( + 'include // provenance gap\n! ("../generated_adapter.rs");\n' + ) + + def test_non_ascii_rust_whitespace_between_include_and_bang_fails_closed(self) -> None: + self._assert_include_trivia_fails_closed( + 'include\u200e!("../generated_adapter.rs");\n' + ) + + def test_non_ascii_rust_whitespace_between_bang_and_delimiter_fails_closed(self) -> None: + self._assert_include_trivia_fails_closed( + 'include!\u200f("../generated_adapter.rs");\n' + ) + + def test_xid_prefix_macro_name_is_not_builtin_include(self) -> None: + root = self._workspace_with_source( + 'macro_rules! a\u0301include { ($path:literal) => {}; }\n' + 'a\u0301include!("../generated_adapter.rs");\n' + ) + + source_contract._assert_no_unmodeled_rust_source_indirection(root) + + def test_xid_prefixed_import_name_is_not_builtin_include_alias(self) -> None: + root = self._workspace_with_source( + 'mod source { pub fn a\u0301include() {} }\n' + 'use source::a\u0301include as embed;\n' + 'pub fn call_surface() { embed(); }\n' + ) + + source_contract._assert_no_unmodeled_rust_source_indirection(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_incremental_cache_input_authority_contract.py b/tests/test_browser_session_incremental_cache_input_authority_contract.py new file mode 100644 index 000000000..2293b6475 --- /dev/null +++ b/tests/test_browser_session_incremental_cache_input_authority_contract.py @@ -0,0 +1,78 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionIncrementalCacheInputAuthorityContractTests(unittest.TestCase): + """Keep repository-selected incremental cache state inside reviewed compiler provenance.""" + + def _assert_fails_closed(self, config_text: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_incremental_cache_path_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "incremental=tools/review-bypass-incremental"]\n' + ) + + def test_target_rustflags_compact_incremental_cache_path_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-Cincremental=tools/review-bypass-incremental\"]\n" + ) + + def test_profile_rustflags_incremental_cache_path_fails_closed(self) -> None: + self._assert_fails_closed( + '[unstable]\nprofile-rustflags = true\n\n[profile.release]\nrustflags = ["--codegen=incremental=tools/review-bypass-incremental"]\n' + ) + + def test_build_rustdocflags_incremental_cache_path_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--codegen=incremental=tools/review-bypass-incremental"]\n' + ) + + def test_doctest_build_arg_incremental_cache_path_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=incremental=tools/review-bypass-incremental"]\n' + ) + + def test_cargo_managed_incremental_toggle_remains_allowed(self) -> None: + directory, root = _workspace_with_config('[build]\nincremental = false\n') + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_lifecycle_contract.py b/tests/test_browser_session_lifecycle_contract.py index 6e4487988..82207fd4a 100644 --- a/tests/test_browser_session_lifecycle_contract.py +++ b/tests/test_browser_session_lifecycle_contract.py @@ -3,6 +3,7 @@ from __future__ import annotations import pathlib +import re import tomllib import unittest @@ -10,9 +11,62 @@ CRATE = ROOT / "crates/originweave-browser-session" +def _inherent_impl_surface(source: str, type_name: str) -> str: + """Return every inherent impl segment for one Rust type without matching sibling request types.""" + + starts = [match.start() for match in re.finditer(r"(?m)^[ \t]*impl(?=\s|<)", source)] + starts.append(len(source)) + segments: list[str] = [] + for index, start in enumerate(starts[:-1]): + segment = source[start : starts[index + 1]] + header = segment.split("{", 1)[0].strip() + target = re.search( + rf"\b{re.escape(type_name)}\s*<[^{{}};]+>\s*$", + header, + ) + if target is None: + continue + if re.search(r"\bfor\s*$", header[: target.start()]): + continue + segments.append(segment) + return "\n".join(segments) + + class BrowserSessionLifecycleContractTests(unittest.TestCase): """Keep presentation mutation authority in an explicit Browser Session domain.""" + def test_recovery_surface_extractor_covers_concrete_and_spaced_generic_impls(self) -> None: + """Raw recovery accessors must not hide in concrete, generic, or indented inherent impls.""" + + hostile = """ +impl BoundBrowserSessionRecovery { + pub fn browser_session(&self) {} +} +impl

BoundBrowserSessionRecovery

{ + pub const fn port(&self) {} +} +mod nested { + impl BoundBrowserSessionRecovery { + pub fn browser_session(&self) {} + } +} +impl

RecoveryContextOperationRequest

{ + pub fn browser_session(&self) {} +} +impl

RecoveryInspection for BoundBrowserSessionRecovery

{ + fn port(&self) {} +} +""" + surface = _inherent_impl_surface(hostile, "BoundBrowserSessionRecovery") + + self.assertIn("impl BoundBrowserSessionRecovery", surface) + self.assertIn("impl

BoundBrowserSessionRecovery

", surface) + self.assertIn("impl BoundBrowserSessionRecovery", surface) + self.assertIn("pub fn browser_session(&self)", surface) + self.assertIn("pub const fn port(&self)", surface) + self.assertNotIn("RecoveryContextOperationRequest", surface) + self.assertNotIn("RecoveryInspection for BoundBrowserSessionRecovery", surface) + def test_browser_session_is_an_independent_workspace_boundary(self) -> None: """Browser Session authority must not be hidden in a driver adapter.""" @@ -30,31 +84,98 @@ def test_browser_session_is_an_independent_workspace_boundary(self) -> None: def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: """Raw driver identifiers must never become caller-mintable authority tokens.""" - source = (CRATE / "src/lib.rs").read_text(encoding="utf-8") - self.assertIn("pub struct BrowserSession", source) - self.assertIn("pub trait DisposableContextPort", source) - self.assertIn("pub struct DisposableIsolationId", source) - self.assertIn("pub struct DisposableContextHandle", source) - self.assertIn("pub struct BrowserSessionIncarnation", source) - self.assertIn("pub struct PresentationMutationAuthority", source) - self.assertIn("pub enum BrowserSessionRecoveryEvidence", source) + recovery_source = (CRATE / "src/recovery.rs").read_text(encoding="utf-8") + recovery_custody_surface = _inherent_impl_surface( + recovery_source, + "BoundBrowserSessionRecovery", + ) + source = "\n".join( + (CRATE / relative_path).read_text(encoding="utf-8") + for relative_path in ( + "src/lib.rs", + "src/browser_session.rs", + "src/recovery.rs", + ) + ) + required_symbols = ( + "pub struct BrowserSession", + "pub struct BoundBrowserSession", + "pub struct BoundBrowserSessionRecovery", + "pub trait DisposableContextPort", + "pub struct DisposableIsolationId", + "pub struct DisposableContextHandle", + "pub struct BrowserSessionIncarnation", + "pub struct PresentationMutationAuthority", + "pub struct DisposableContextCreateRequest", + "pub struct DisposableContextCreateCompletion", + "pub enum DisposableContextCreateDisposition", + "pub enum DisposableContextCreateCompletionError", + "pub struct DisposableContextDestroyRequest", + "pub enum BrowserSessionRecoveryEvidence", + "pub struct AuthorizedContextOperationRequest", + "pub enum AuthorizedContextOperationError", + "pub trait AuthorizedContextOperationPort", + "pub enum DisposableContextCreateError", + "pub enum DisposableContextDestroyError", + "pub fn abandoned_bound_session_count", + "pub fn finish", + "pub fn into_recovery", + "pub fn execute_authorized_context_operation", + ) + for symbol in required_symbols: + self.assertIn(symbol, source) + self.assertIn("BrowserSessionState::RecoveryRequired", source) - self.assertIn("pub enum DisposableContextCreateError", source) - self.assertIn("pub enum DisposableContextDestroyError", source) + self.assertIn("BrowserSessionState::TransportLost", source) self.assertNotIn("pub enum DisposableContextPortError", source) + self.assertNotIn("DisposableContextPortId", source) + self.assertNotIn("fn port_id(&self)", source) + self.assertNotIn("pub const fn lifecycle_port", source) + self.assertNotIn("pub fn lifecycle_port", source) + self.assertNotIn("pub fn create_disposable_context", source) + self.assertIn("pub fn bind_lifecycle_port", source) + self.assertIn("attempt_epoch: BrowserContextEpoch", source) + self.assertIn("fn complete_disposable_context_creation(", source) + self.assertIn("DisposableContextCreateDisposition::Accepted", source) + self.assertIn("DisposableContextCreateDisposition::Rejected", source) self.assertIn("CreateFailedClean", source) self.assertIn("CreateFailedUncertain", source) self.assertIn("PartialCreationIsolation", source) self.assertIn("DuplicateAdapterHandle", source) + self.assertIn("UnsettledAdapterHandle", source) self.assertIn("UnprovenDestruction", source) - self.assertIn("create_disposable_context", source) + self.assertIn("RecoveryRequiredOwnedHandle", source) + self.assertIn("TransportLossOwnedHandle", source) + self.assertIn("create_disposable_context_with_port", source) self.assertIn("advance_context_epoch", source) self.assertIn("record_transport_loss", source) self.assertIn("transport_is_lost", source) self.assertIn("recovery_evidence", source) - self.assertIn("user-context identifier", source) + self.assertIn("AuthorizedContextOperationError::BrowserSession", source) + self.assertIn("AuthorizedContextOperationError::Adapter", source) + self.assertIn("#[must_use =", source) + self.assertIn("impl

Drop for BoundBrowserSession

", source) + self.assertIn("", source) + self.assertIn("user-context", source) self.assertIn("Reconstructing cleanup authority", source) self.assertIn("sequential_incarnation_reuse_rejects_stale_authority", source) + self.assertIn("pub fn finish(&mut self)", source) + self.assertNotIn("pub fn finish(mut self)", source) + self.assertNotIn("pub const fn port", source) + self.assertNotIn("pub fn port", source) + + self.assertIn("pub const fn state(&self) -> BrowserSessionState", recovery_custody_surface) + self.assertIn("pub fn recovery_evidence(&self)", recovery_custody_surface) + self.assertIn("pub fn create_attempt_recovery_evidence(&self)", recovery_custody_surface) + self.assertNotIn("pub const fn browser_session(&self)", recovery_custody_surface) + self.assertNotIn("pub fn browser_session(&self)", recovery_custody_surface) + self.assertNotIn("pub const fn port", recovery_custody_surface) + self.assertNotIn("pub fn port", recovery_custody_surface) + self.assertIn( + "recovery.browser_session().presentation_authority(context)", + recovery_source, + ) + self.assertGreaterEqual(recovery_source.count("```compile_fail"), 6) authority_impl = source.split("impl PresentationMutationAuthority", 1)[1].split( "enum OwnedContextState", 1 @@ -62,28 +183,143 @@ def test_domain_source_mints_authority_only_from_owned_lifecycle(self) -> None: self.assertNotIn("pub fn new", authority_impl) self.assertNotIn("pub const fn new", authority_impl) - def test_hostile_recovery_and_reincarnation_fixtures_remain_external(self) -> None: - """Recovery and sequential reuse invariants must be executable outside crate internals.""" + create_request_impl = source.split("impl DisposableContextCreateRequest", 1)[1].split( + "pub enum DisposableContextCreateDisposition", 1 + )[0] + completion_impl = source.split("impl DisposableContextCreateCompletion", 1)[1].split( + "pub enum DisposableContextCreateCompletionError", 1 + )[0] + destroy_request_impl = source.split("impl DisposableContextDestroyRequest", 1)[1].split( + "pub trait DisposableContextPort", 1 + )[0] + operation_request_impl = source.split("impl AuthorizedContextOperationRequest", 1)[1].split( + "pub enum AuthorizedContextOperationError", 1 + )[0] + for request_impl in ( + create_request_impl, + completion_impl, + destroy_request_impl, + operation_request_impl, + ): + self.assertNotIn("pub fn new", request_impl) + self.assertNotIn("pub const fn new", request_impl) - destroy_hostile = ( - CRATE / "tests/destroy_failure_requires_recovery.rs" - ).read_text(encoding="utf-8") - reincarnation_hostile = ( - CRATE / "tests/sequential_incarnation_reuse.rs" - ).read_text(encoding="utf-8") - self.assertIn( - "destroy_failure_requires_recovery_before_any_new_authority", - destroy_hostile, + def test_hostile_recovery_binding_and_operation_fixtures_remain_external(self) -> None: + """Recovery, binding, transactions, operations, and abandonment execute externally.""" + + destroy_hostile = (CRATE / "tests/destroy_failure_requires_recovery.rs").read_text( + encoding="utf-8" + ) + reincarnation_hostile = (CRATE / "tests/sequential_incarnation_reuse.rs").read_text( + encoding="utf-8" + ) + preflight_hostile = (CRATE / "tests/lifecycle_port_preflight_side_effect.rs").read_text( + encoding="utf-8" + ) + substitution_hostile = (CRATE / "tests/lifecycle_port_same_id_spoof.rs").read_text( + encoding="utf-8" + ) + transaction_hostile = (CRATE / "tests/creation_transaction_completion.rs").read_text( + encoding="utf-8" + ) + debug_hostile = (CRATE / "tests/bound_session_debug_redaction.rs").read_text( + encoding="utf-8" ) + transport_hostile = (CRATE / "tests/transport_loss_recovery_evidence.rs").read_text( + encoding="utf-8" + ) + recovery_hostile = (CRATE / "tests/recovery_required_sibling_evidence.rs").read_text( + encoding="utf-8" + ) + recovery_handoff = (CRATE / "tests/recovery_owner_handoff.rs").read_text( + encoding="utf-8" + ) + hot_ownership = (CRATE / "tests/proven_destroy_releases_hot_ownership.rs").read_text( + encoding="utf-8" + ) + operation_hostile = (CRATE / "tests/authorized_context_operation.rs").read_text( + encoding="utf-8" + ) + abandonment_hostile = (CRATE / "tests/bound_session_abandonment.rs").read_text( + encoding="utf-8" + ) + + self.assertIn("destroy_failure_requires_recovery_before_any_new_authority", destroy_hostile) self.assertIn("BrowserSessionRecoveryEvidence::UnprovenDestruction", destroy_hostile) - self.assertIn("assert!(session.record_transport_loss());", destroy_hostile) - self.assertIn("assert!(!session.record_transport_loss());", destroy_hostile) + self.assertIn("assert!(bound.record_transport_loss());", destroy_hostile) + self.assertIn("assert!(!bound.record_transport_loss());", destroy_hostile) + self.assertNotIn("lifecycle_port()", destroy_hostile) + + self.assertIn("stale_authority_cannot_cross_sequential_session_incarnations", reincarnation_hostile) + self.assertIn("assert_ne!(\n bound_a.browser_session().incarnation(),", reincarnation_hostile) + self.assertIn("assert!(destroy_b.borrow().is_empty());", reincarnation_hostile) + self.assertNotIn("lifecycle_port()", reincarnation_hostile) + + self.assertIn("lifecycle_binding_invokes_no_adapter_callback_before_authorized_create", preflight_hostile) + self.assertIn("identity_callbacks", preflight_hostile) + self.assertNotIn("bound.lifecycle_port()", preflight_hostile) + + self.assertIn("distinct_adapter_cannot_be_substituted_for_create_after_binding", substitution_hostile) + self.assertIn("distinct_adapter_cannot_be_substituted_for_destroy_after_binding", substitution_hostile) + self.assertNotIn("bound.lifecycle_port()", substitution_hostile) + + self.assertIn("accepted_and_rejected_create_candidates_are_correlated_by_exact_attempt", transaction_hostile) + self.assertIn("request.attempt_epoch().value()", transaction_hostile) + self.assertIn("DisposableContextCreateDisposition::Accepted", transaction_hostile) + self.assertIn("DisposableContextCreateDisposition::Rejected", transaction_hostile) + self.assertIn("assert!(ledger.pending.is_empty());", transaction_hostile) + + self.assertIn("bound_session_debug_never_executes_or_exposes_adapter_debug", debug_hostile) + self.assertIn("adapter-secret-sentinel", debug_hostile) + self.assertIn("debug_callbacks.get(),\n 0", debug_hostile) + + self.assertIn("transport_loss_preserves_exact_owned_handle_as_non_authorizing_recovery_evidence", transport_hostile) + self.assertIn("transport-user-context-501", transport_hostile) + self.assertIn("recovery_evidence().len(),\n 1", transport_hostile) + + self.assertIn("recovery_required_projects_exact_handles_for_indirectly_uncertain_siblings", recovery_hostile) + self.assertIn("BrowserSessionRecoveryEvidence::RecoveryRequiredOwnedHandle", recovery_hostile) + self.assertIn("indirectly invalidated sibling", recovery_hostile) + + self.assertIn( + "unproven_destroy_hands_exact_bound_adapter_and_evidence_to_recovery_owner", + recovery_handoff, + ) + self.assertIn( + "transport_loss_hands_exact_bound_adapter_and_evidence_to_recovery_owner", + recovery_handoff, + ) + self.assertIn(".into_recovery()", recovery_handoff) + self.assertIn("recovery.state()", recovery_handoff) + self.assertIn("recovery.recovery_evidence()", recovery_handoff) + self.assertNotIn("recovery.browser_session()", recovery_handoff) + self.assertIn("handoff must move, not replace, the bound adapter", recovery_handoff) + self.assertIn("handoff must not imply cleanup I/O", recovery_handoff) + self.assertIn("transport loss is not destruction proof", recovery_handoff) + self.assertIn( - "stale_authority_cannot_cross_sequential_session_incarnations", - reincarnation_hostile, + "proven_destroy_releases_hot_ownership_without_resurrecting_stale_authority", + hot_ownership, ) - self.assertIn("assert_ne!(session_a.incarnation(), session_b.incarnation());", reincarnation_hostile) - self.assertIn("assert!(port_b.destroy_incarnations.is_empty());", reincarnation_hostile) + self.assertIn("for _ in 0..256", hot_ownership) + self.assertIn("Err(BrowserSessionError::ContextNotOwned)", hot_ownership) + self.assertIn("Err(BrowserSessionError::AuthorityMismatch)", hot_ownership) + self.assertIn("stale authority must fail before lifecycle adapter I/O", hot_ownership) + self.assertIn("create_calls.get(), 258", hot_ownership) + self.assertIn("destroy_calls.get(), 258", hot_ownership) + + self.assertIn("authorized_operation_uses_exact_bound_port_and_rejects_stale_authority_before_io", operation_hostile) + self.assertIn("AuthorizedContextOperationError::BrowserSession", operation_hostile) + self.assertIn("AuthorizedContextOperationError::Adapter", operation_hostile) + self.assertIn("stale authority must fail before the bound adapter", operation_hostile) + + self.assertIn("dropping_unresolved_bound_session_is_observable_without_implicit_browser_io", abandonment_hostile) + self.assertIn("abandoned_bound_session_count", abandonment_hostile) + self.assertIn("Drop must never pretend synchronous browser cleanup succeeded", abandonment_hostile) + self.assertIn("failed_finish_retains_same_bound_owner_for_cleanup_and_retry", abandonment_hostile) + self.assertIn("same bound lifecycle owner must remain available for cleanup", abandonment_hostile) + self.assertIn("proven_destruction_can_finish_without_abandonment_path", abandonment_hostile) + self.assertIn("bound.finish()", abandonment_hostile) def test_architecture_decision_and_traceability_are_explicit(self) -> None: """Disposable ownership must remain a Proposed, standards-traced active-PR claim.""" @@ -91,38 +327,114 @@ def test_architecture_decision_and_traceability_are_explicit(self) -> None: adr = (ROOT / "docs/adr/0114-browser-session-disposable-context-authority.md").read_text( encoding="utf-8" ) + recovery_adr = ( + ROOT / "docs/adr/0116-browser-session-recovery-custody-and-hot-ownership.md" + ).read_text(encoding="utf-8") trace = (ROOT / "docs/traceability/browser-session-lifecycle-authority.md").read_text( encoding="utf-8" ) uml = (ROOT / "docs/uml/browser-session-lifecycle-authority.md").read_text( encoding="utf-8" ) - self.assertIn("Status: Proposed", adr) - self.assertIn("WD-webdriver-bidi-20260909", adr) - self.assertIn("RecoveryRequired", adr) - self.assertIn("BrowserSessionIncarnation", adr) - self.assertIn("BrowserSessionRecoveryEvidence", adr) - self.assertIn("DisposableContextCreateError", adr) - self.assertIn("DisposableContextDestroyError", adr) - self.assertIn("CreateFailedClean", adr) - self.assertIn("CreateFailedUncertain", adr) - self.assertIn("transport liveness", adr) - self.assertIn("sequential", adr) - self.assertIn("unproven destruction", adr) - self.assertIn("IMPLEMENTED_ON_ACTIVE_PR", trace) - self.assertIn("RecoveryRequired", trace) - self.assertIn("BrowserSessionIncarnation", trace) - self.assertIn("lossless recovery evidence", trace) - self.assertIn("transport liveness", trace) - self.assertIn("sequential ABA", trace) - self.assertIn("command ACK", trace) - self.assertIn("PresentationMutationAuthority", uml) - self.assertIn("BrowserSessionIncarnation", uml) - self.assertIn("RecoveryRequired", uml) - self.assertIn("transport_lost", uml) - self.assertIn("DisposableContextDestroyError / cleanup unproven", uml) + for token in ( + "Status: Proposed", + "docs/traceability/webdriver-bidi-publication-current.md", + "RecoveryRequired", + "RecoveryRequiredOwnedHandle", + "BrowserSessionIncarnation", + "BrowserSessionRecoveryEvidence", + "DisposableContextCreateRequest", + "DisposableContextCreateCompletion", + "DisposableContextDestroyRequest", + "BoundBrowserSession", + "linear lifecycle-port binding", + "no public raw port accessor", + "per-create transaction", + "DisposableContextCreateError", + "DisposableContextDestroyError", + "CreateFailedClean", + "CreateFailedUncertain", + "transport liveness", + "sequential", + "unproven destruction", + "AuthorizedContextOperationPort", + "TransportLossOwnedHandle", + "abandoned_bound_session_count", + "failed `finish()`", + "Drop", + "finish()", + ): + self.assertIn(token, adr) + + for token in ( + "ADR 0116", + "Status: Proposed", + "Extends: ADR 0114", + "BoundBrowserSessionRecovery", + "into_recovery(self)", + "same non-`Clone` adapter instance", + "non-authorizing", + "hot command-authority state", + "ContextNotOwned", + "AuthorityMismatch", + "monotonic", + "process-restart", + "#316", + "compile_fail", + "protected `main`", + "Assumptions and authority boundaries", + "Migration and rollback", + "Supersession / reversal conditions", + ): + self.assertIn(token, recovery_adr) + + for token in ( + "IMPLEMENTED_ON_ACTIVE_PR", + "ADR 0116", + "BoundBrowserSession", + "BoundBrowserSessionRecovery", + "DisposableContextCreateCompletion", + "per-create transaction", + "no public raw port accessor", + "RecoveryRequired", + "RecoveryRequiredOwnedHandle", + "BrowserSessionIncarnation", + "lossless recovery evidence", + "transport liveness", + "Sequential ABA", + "command ACK", + "AuthorizedContextOperationPort", + "TransportLossOwnedHandle", + "abandoned_bound_session_count", + "durable crash/process-restart recovery", + "258-generation", + "ContextNotOwned", + "AuthorityMismatch", + ): + self.assertIn(token, trace) + + for token in ( + "PresentationMutationAuthority", + "BoundBrowserSession", + "BoundBrowserSessionRecovery", + "RecoveryCustody", + "DisposableContextCreateCompletion", + "BrowserSessionIncarnation", + "RecoveryRequired", + "RecoveryRequiredOwnedHandle", + "transport_lost", + "DisposableContextDestroyError / cleanup unproven", + "AuthorizedContextOperationRequest", + "AuthorizedContextOperationError::BrowserSession", + "TransportLossOwnedHandle", + "abandoned_bound_session_count", + "finish()", + "remove live hot-ownership record", + "258 ownership generations", + ): + self.assertIn(token, uml) self.assertNotIn("IMPLEMENTED_ON_PROTECTED_MAIN", trace) if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file diff --git a/tests/test_browser_session_linker_bare_implicit_script_contract.py b/tests/test_browser_session_linker_bare_implicit_script_contract.py new file mode 100644 index 000000000..8631d5fc2 --- /dev/null +++ b/tests/test_browser_session_linker_bare_implicit_script_contract.py @@ -0,0 +1,80 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLinkerBareImplicitScriptContractTests(unittest.TestCase): + """Keep bare extensionless implicit linker inputs inside reviewed provenance.""" + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (root / "review-bypass-input").write_text( + "INPUT(tools/review-bypass-object.o)\n", + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def _assert_bare_input_fails_closed(self, config_text: str) -> None: + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_direct_bare_extensionless_implicit_script_fails_closed(self) -> None: + self._assert_bare_input_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=review-bypass-input"]\n' + ) + + def test_forwarded_bare_extensionless_implicit_script_fails_closed(self) -> None: + for forwarded in ( + "-Wl,review-bypass-input", + "--for-linker=review-bypass-input", + "-Xlinker review-bypass-input", + ): + with self.subTest(forwarded=forwarded): + self._assert_bare_input_fails_closed( + f'[build]\nrustflags = ["-C", "link-args={forwarded}"]\n' + ) + + def test_separate_z_operand_remains_allowed(self) -> None: + for forwarded in ( + "-z relro", + "-Wl,-z,relro", + "--for-linker=-z,relro", + "-Xlinker -z -Xlinker relro", + ): + with self.subTest(forwarded=forwarded): + root = self._workspace_with_config( + f'[build]\nrustflags = ["-C", "link-args={forwarded}"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_default_library_search_path_authority_contract.py b/tests/test_browser_session_linker_default_library_search_path_authority_contract.py new file mode 100644 index 000000000..10f3b14bf --- /dev/null +++ b/tests/test_browser_session_linker_default_library_search_path_authority_contract.py @@ -0,0 +1,79 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionLinkerDefaultLibrarySearchPathAuthorityContractTests(unittest.TestCase): + """Keep GNU ld default-library search-path overrides in reviewed provenance.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_compact_y_search_path_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-Ytools/shadow-libs"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_compact_y_search_path_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=--for-linker=-Ytools/target-libs\"]\n", + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_compact_y_search_path_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-arg=-Wl,-Ytools/doc-libs"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_forwarded_compact_y_search_path_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,-Ytools/doctest-libs"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_relro_toggle_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-z,relro"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_default_script_authority_contract.py b/tests/test_browser_session_linker_default_script_authority_contract.py new file mode 100644 index 000000000..f02b2c991 --- /dev/null +++ b/tests/test_browser_session_linker_default_script_authority_contract.py @@ -0,0 +1,79 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionDefaultLinkerScriptAuthorityContractTests(unittest.TestCase): + """Keep GNU/LLD default linker scripts inside reviewed Browser Session provenance.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_default_script_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--default-script=tools/review-bypass-default.lds"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_for_linker_default_script_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=--for-linker=--default-script=tools/review-bypass-default.lds\"]\n", + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_xlinker_default_script_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-args=-Xlinker --default-script=tools/review-bypass-default.lds"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_forwarded_default_script_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,--default-script=tools/review-bypass-default.lds"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_non_script_linker_hardening_option_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-z,relro"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_driver_override_contract.py b/tests/test_browser_session_linker_driver_override_contract.py new file mode 100644 index 000000000..fb14aaa7c --- /dev/null +++ b/tests/test_browser_session_linker_driver_override_contract.py @@ -0,0 +1,57 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLinkerDriverOverrideContractTests(unittest.TestCase): + """Reject Cargo-owned codegen flags that re-select the linker behind the compiler driver.""" + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def _assert_linker_driver_override_fails_closed(self, config_text: str) -> None: + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_link_arg_fuse_ld_override_fails_closed(self) -> None: + self._assert_linker_driver_override_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-fuse-ld=review-bypass-linker"]\n' + ) + + def test_target_long_link_args_fuse_ld_override_fails_closed(self) -> None: + self._assert_linker_driver_override_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"--codegen=link-args=-fuse-ld=review-bypass-linker\"]\n" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_driver_search_path_contract.py b/tests/test_browser_session_linker_driver_search_path_contract.py new file mode 100644 index 000000000..794c0c213 --- /dev/null +++ b/tests/test_browser_session_linker_driver_search_path_contract.py @@ -0,0 +1,57 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLinkerDriverSearchPathContractTests(unittest.TestCase): + """Reject Cargo-owned linker-driver search paths that can re-select tool executables.""" + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def _assert_driver_search_path_fails_closed(self, config_text: str) -> None: + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_link_arg_driver_search_path_fails_closed(self) -> None: + self._assert_driver_search_path_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Btools/review-bypass-binutils"]\n' + ) + + def test_target_long_link_args_driver_search_path_fails_closed(self) -> None: + self._assert_driver_search_path_fails_closed( + "[target.'cfg(unix)']\nrustdocflags = [\"--codegen=link-args=-B tools/review-bypass-binutils\"]\n" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_driver_specs_contract.py b/tests/test_browser_session_linker_driver_specs_contract.py new file mode 100644 index 000000000..129c8c4c8 --- /dev/null +++ b/tests/test_browser_session_linker_driver_specs_contract.py @@ -0,0 +1,77 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLinkerDriverSpecsContractTests(unittest.TestCase): + """Keep GCC driver spec-file authority inside the reviewed Browser Session execution boundary.""" + + def _workspace_with_flags(self, rustflags: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text( + f'[build]\nrustflags = {rustflags}\n', + encoding="utf-8", + ) + return root + + def test_repository_driver_specs_file_fails_closed(self) -> None: + root = self._workspace_with_flags( + '["-C", "link-arg=-specs=tools/review-bypass.specs"]' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_driver_specs_inside_link_args_fails_closed(self) -> None: + root = self._workspace_with_flags( + '["--codegen=link-args=-specs=tools/review-bypass.specs"]' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_split_driver_specs_file_fails_closed(self) -> None: + root = self._workspace_with_flags( + '["-C", "link-arg=-specs", "-C", "link-arg=tools/review-bypass.specs"]' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_split_driver_specs_inside_link_args_fails_closed(self) -> None: + root = self._workspace_with_flags( + '["--codegen=link-args=-specs tools/review-bypass.specs"]' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_unrelated_driver_link_argument_remains_allowed(self) -> None: + root = self._workspace_with_flags('["-C", "link-arg=-pthread"]') + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_driver_wrapper_contract.py b/tests/test_browser_session_linker_driver_wrapper_contract.py new file mode 100644 index 000000000..3af4a4e3b --- /dev/null +++ b/tests/test_browser_session_linker_driver_wrapper_contract.py @@ -0,0 +1,63 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLinkerDriverWrapperContractTests(unittest.TestCase): + """Keep GCC subcommand wrapper authority inside the reviewed Browser Session execution boundary.""" + + def _workspace_with_flags(self, rustflags: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text( + f'[build]\nrustflags = {rustflags}\n', + encoding="utf-8", + ) + return root + + def test_repository_split_driver_wrapper_fails_closed(self) -> None: + root = self._workspace_with_flags( + '["-C", "link-arg=-wrapper", "-C", "link-arg=tools/review-wrapper,--args"]' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_driver_wrapper_inside_link_args_fails_closed(self) -> None: + root = self._workspace_with_flags( + '["--codegen=link-args=-wrapper tools/review-wrapper,--args"]' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_unrelated_driver_link_argument_remains_allowed(self) -> None: + root = self._workspace_with_flags('["-C", "link-arg=-pthread"]') + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_implicit_script_contract.py b/tests/test_browser_session_linker_implicit_script_contract.py new file mode 100644 index 000000000..a822d9c83 --- /dev/null +++ b/tests/test_browser_session_linker_implicit_script_contract.py @@ -0,0 +1,81 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLinkerImplicitScriptContractTests(unittest.TestCase): + """Keep extensionless implicit linker-script inputs inside reviewed provenance.""" + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + tools = root / "tools" + tools.mkdir() + (tools / "review-bypass-input").write_text( + "INPUT(tools/review-bypass-object.o)\n", + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def _assert_implicit_script_fails_closed(self, config_text: str) -> None: + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_direct_extensionless_implicit_script_link_arg_fails_closed(self) -> None: + self._assert_implicit_script_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=tools/review-bypass-input"]\n' + ) + + def test_forwarded_extensionless_implicit_script_fails_closed(self) -> None: + for forwarded in ( + "-Wl,tools/review-bypass-input", + "--for-linker=tools/review-bypass-input", + "-Xlinker tools/review-bypass-input", + ): + with self.subTest(forwarded=forwarded): + self._assert_implicit_script_fails_closed( + f'[build]\nrustflags = ["-C", "link-args={forwarded}"]\n' + ) + + def test_bare_linker_option_operand_remains_allowed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-z,relro"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_option_only_link_arguments_remain_allowed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--as-needed"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_input_remap_authority_contract.py b/tests/test_browser_session_linker_input_remap_authority_contract.py new file mode 100644 index 000000000..0d6937152 --- /dev/null +++ b/tests/test_browser_session_linker_input_remap_authority_contract.py @@ -0,0 +1,79 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionLinkerInputRemapAuthorityContractTests(unittest.TestCase): + """Keep linker input-remapping policy from replacing reviewed Browser Session link inputs.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_inline_remap_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--remap-inputs=liboriginweave.a=tools/review-bypass/liboriginweave.a"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_remap_file_equals_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=--for-linker=--remap-inputs-file=tools/review-bypass-remaps.txt\"]\n", + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_single_dash_remap_file_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-arg=-Wl,-remap-inputs-file=tools/review-bypass-remaps.txt"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_forwarded_single_dash_inline_remap_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,-remap-inputs=liboriginweave.a=tools/review-bypass/liboriginweave.a"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_non_input_linker_hardening_option_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-z,relro"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_just_symbols_input_authority_contract.py b/tests/test_browser_session_linker_just_symbols_input_authority_contract.py new file mode 100644 index 000000000..89cc84703 --- /dev/null +++ b/tests/test_browser_session_linker_just_symbols_input_authority_contract.py @@ -0,0 +1,79 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionLinkerJustSymbolsInputAuthorityContractTests(unittest.TestCase): + """Keep GNU/LLD just-symbols files outside repository-owned Browser Session builds.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_just_symbols_equals_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--just-symbols=tools/review-bypass-symbols.o"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_compact_R_file_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=--for-linker=-Rtools/review-bypass-symbols.o\"]\n", + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_just_symbols_equals_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-arg=-Wl,--just-symbols=tools/review-bypass-symbols.o"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_forwarded_just_symbols_equals_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,--just-symbols=tools/review-bypass-symbols.o"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_non_input_linker_hardening_option_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-z,relro"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_library_alias_authority_contract.py b/tests/test_browser_session_linker_library_alias_authority_contract.py new file mode 100644 index 000000000..b61f2c8f4 --- /dev/null +++ b/tests/test_browser_session_linker_library_alias_authority_contract.py @@ -0,0 +1,36 @@ +import importlib.util +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLinkerLibraryAliasAuthorityContractTests(unittest.TestCase): + """Keep GNU-compatible long-form native-library selection inside reviewed linker provenance.""" + + def test_joined_double_dash_library_alias_fails_closed(self) -> None: + rustflags = ["-C", "link-arg=-Wl,--library=review_bypass"] + self.assertTrue(authority._flags_select_linker(rustflags)) + + def test_rustdoc_doctest_forwarding_cannot_select_joined_library_alias(self) -> None: + rustdocflags = [ + "--doctest-build-arg=-C", + "--doctest-build-arg=link-arg=-Wl,--library=review_bypass", + ] + self.assertTrue(authority._flags_select_rustdoc_doctest_compiler_authority(rustdocflags)) + + def test_typed_linker_control_remains_allowed(self) -> None: + rustflags = ["-C", "link-arg=-Wl,-z,relro"] + self.assertFalse(authority._flags_select_linker(rustflags)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_mri_script_authority_contract.py b/tests/test_browser_session_linker_mri_script_authority_contract.py new file mode 100644 index 000000000..5c120e97d --- /dev/null +++ b/tests/test_browser_session_linker_mri_script_authority_contract.py @@ -0,0 +1,79 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionMriLinkerScriptAuthorityContractTests(unittest.TestCase): + """Keep GNU MRI linker command files inside reviewed Browser Session provenance.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_mri_script_equals_form_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--mri-script=tools/review-bypass-mri.cmd"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_for_linker_mri_script_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=--for-linker=--mri-script=tools/review-bypass-mri.cmd\"]\n", + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_xlinker_mri_script_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-args=-Xlinker --mri-script=tools/review-bypass-mri.cmd"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_forwarded_mri_script_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,--mri-script=tools/review-bypass-mri.cmd"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_non_script_linker_hardening_option_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-z,relro"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_plugin_contract.py b/tests/test_browser_session_linker_plugin_contract.py new file mode 100644 index 000000000..2ada09353 --- /dev/null +++ b/tests/test_browser_session_linker_plugin_contract.py @@ -0,0 +1,86 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLinkerPluginContractTests(unittest.TestCase): + """Keep linker-plugin code loading inside the reviewed Browser Session execution boundary.""" + + def _workspace_with_flags(self, rustflags: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text( + f'[build]\nrustflags = {rustflags}\n', + encoding="utf-8", + ) + return root + + def _assert_plugin_override_fails_closed(self, rustflags: str) -> None: + root = self._workspace_with_flags(rustflags) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_wl_linker_plugin_fails_closed(self) -> None: + self._assert_plugin_override_fails_closed( + '["-C", "link-arg=-Wl,-plugin,tools/review-bypass-linker.so"]' + ) + + def test_repository_xlinker_plugin_fails_closed(self) -> None: + self._assert_plugin_override_fails_closed( + '["-C", "link-arg=-Xlinker", "-C", "link-arg=-plugin", "-C", "link-arg=-Xlinker", "-C", "link-arg=tools/review-bypass-linker.so"]' + ) + + def test_repository_for_linker_plugin_fails_closed(self) -> None: + self._assert_plugin_override_fails_closed( + '["-C", "link-arg=--for-linker=-plugin=tools/review-bypass-linker.so"]' + ) + + def test_repository_lld_load_pass_plugin_fails_closed(self) -> None: + self._assert_plugin_override_fails_closed( + '["-C", "link-arg=-Wl,--load-pass-plugin=tools/review-bypass-pass.so"]' + ) + + def test_non_plugin_wl_forwarding_remains_allowed(self) -> None: + root = self._workspace_with_flags('["-C", "link-arg=-Wl,--as-needed"]') + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_non_plugin_xlinker_forwarding_remains_allowed(self) -> None: + root = self._workspace_with_flags( + '["-C", "link-arg=-Xlinker", "-C", "link-arg=--as-needed"]' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_non_plugin_for_linker_forwarding_remains_allowed(self) -> None: + root = self._workspace_with_flags( + '["-C", "link-arg=--for-linker=--as-needed"]' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_plugin_lto_contract.py b/tests/test_browser_session_linker_plugin_lto_contract.py new file mode 100644 index 000000000..88b79fcd2 --- /dev/null +++ b/tests/test_browser_session_linker_plugin_lto_contract.py @@ -0,0 +1,62 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLinkerPluginLtoContractTests(unittest.TestCase): + """Keep repository-selected rustc linker plugins inside reviewed execution provenance.""" + + def test_repository_linker_plugin_lto_paths_fail_closed(self) -> None: + hostile_configs = ( + '[build]\nrustflags = ["-C", "linker-plugin-lto=tools/review-bypass-llvmgold.so"]\n', + '[build]\nrustflags = ["-Clinker-plugin-lto=tools/review-bypass-llvmgold.so"]\n', + "[target.'cfg(unix)']\nrustflags = [\"--codegen=linker-plugin-lto=tools/review-bypass-llvmgold.so\"]\n", + '[build]\nrustdocflags = ["-C", "linker-plugin-lto=tools/review-bypass-llvmgold.so"]\n', + ) + for config_text in hostile_configs: + with self.subTest(config_text=config_text): + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_boolean_linker_plugin_lto_settings_do_not_name_repository_plugin_paths(self) -> None: + for option in ("linker-plugin-lto", "linker-plugin-lto=yes", "linker-plugin-lto=no"): + with self.subTest(option=option): + root = self._workspace_with_config( + f'[build]\nrustflags = ["-C", "{option}"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = self.enterContext(tempfile.TemporaryDirectory()) + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_positional_input_contract.py b/tests/test_browser_session_linker_positional_input_contract.py new file mode 100644 index 000000000..523af421c --- /dev/null +++ b/tests/test_browser_session_linker_positional_input_contract.py @@ -0,0 +1,74 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLinkerPositionalInputContractTests(unittest.TestCase): + """Keep positional native link inputs inside the reviewed Browser Session provenance boundary.""" + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def _assert_positional_input_fails_closed(self, config_text: str) -> None: + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_direct_positional_object_link_arg_fails_closed(self) -> None: + self._assert_positional_input_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=tools/review-bypass-object.o"]\n' + ) + + def test_target_positional_archive_link_args_fails_closed(self) -> None: + self._assert_positional_input_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-args=tools/review-bypass-archive.a\"]\n" + ) + + def test_forwarded_positional_object_fails_closed(self) -> None: + for forwarded in ( + "-Wl,tools/review-bypass-object.o", + "--for-linker=tools/review-bypass-object.o", + "-Xlinker tools/review-bypass-object.o", + ): + with self.subTest(forwarded=forwarded): + self._assert_positional_input_fails_closed( + f'[build]\nrustflags = ["-C", "link-args={forwarded}"]\n' + ) + + def test_option_only_link_arguments_remain_allowed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--as-needed"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_response_file_contract.py b/tests/test_browser_session_linker_response_file_contract.py new file mode 100644 index 000000000..7c633d493 --- /dev/null +++ b/tests/test_browser_session_linker_response_file_contract.py @@ -0,0 +1,56 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLinkerResponseFileContractTests(unittest.TestCase): + """Keep compiler-driver response files inside the reviewed linker-execution boundary.""" + + def _workspace_with_response_file(self, flags: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text( + f'[build]\nrustflags = ["-C", "link-arg={flags}"]\n', + encoding="utf-8", + ) + tools = root / "tools" + tools.mkdir() + (tools / "linker.rsp").write_text( + "-Btools/review-bypass-binutils\n", + encoding="utf-8", + ) + return root + + def test_repository_linker_driver_response_file_fails_closed(self) -> None: + root = self._workspace_with_response_file("@tools/linker.rsp") + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_runtime_audit_authority_contract.py b/tests/test_browser_session_linker_runtime_audit_authority_contract.py new file mode 100644 index 000000000..1690d8f92 --- /dev/null +++ b/tests/test_browser_session_linker_runtime_audit_authority_contract.py @@ -0,0 +1,85 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionLinkerRuntimeAuditAuthorityContractTests(unittest.TestCase): + """Keep ELF rtld-audit selection inside reviewed Browser Session provenance.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_audit_library_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--audit=tools/review-bypass-audit.so"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_depaudit_library_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=--for-linker=--depaudit=tools/review-bypass-audit.so\"]\n", + "rustflags:codegen linker", + ) + + def test_single_dash_long_audit_alias_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-audit=tools/review-bypass-audit.so"]\n', + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_solaris_depaudit_short_form_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-arg=-Wl,-Ptools/review-bypass-audit.so"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_forwarded_audit_library_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,--audit=tools/review-bypass-audit.so"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_unrelated_linker_option_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--as-needed"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_runtime_filter_authority_contract.py b/tests/test_browser_session_linker_runtime_filter_authority_contract.py new file mode 100644 index 000000000..445c17e9e --- /dev/null +++ b/tests/test_browser_session_linker_runtime_filter_authority_contract.py @@ -0,0 +1,92 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionLinkerRuntimeFilterAuthorityContractTests(unittest.TestCase): + """Keep ELF DT_AUXILIARY/DT_FILTER runtime implementation selection in reviewed provenance.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_auxiliary_library_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--auxiliary=tools/review-bypass-impl.so"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_filter_library_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=--for-linker=--filter=tools/review-bypass-impl.so\"]\n", + "rustflags:codegen linker", + ) + + def test_single_dash_long_auxiliary_alias_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-auxiliary=tools/review-bypass-impl.so"]\n', + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_compact_filter_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-arg=-Wl,-Ftools/review-bypass-impl.so"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_forwarded_compact_auxiliary_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,-ftools/review-bypass-impl.so"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_fini_symbol_selector_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-fini=originweave_fini"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_unrelated_linker_option_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--as-needed"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_runtime_loader_authority_contract.py b/tests/test_browser_session_linker_runtime_loader_authority_contract.py new file mode 100644 index 000000000..132bf9680 --- /dev/null +++ b/tests/test_browser_session_linker_runtime_loader_authority_contract.py @@ -0,0 +1,85 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionLinkerRuntimeLoaderAuthorityContractTests(unittest.TestCase): + """Keep ELF runtime-loader selection inside reviewed Browser Session provenance.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_dynamic_linker_equals_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--dynamic-linker=tools/review-bypass-loader"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_compact_dynamic_linker_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=--for-linker=-Itools/review-bypass-loader\"]\n", + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_dynamic_linker_equals_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-arg=-Wl,--dynamic-linker=tools/review-bypass-loader"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_forwarded_dynamic_linker_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,--dynamic-linker=tools/review-bypass-loader"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_runtime_loader_suppression_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--no-dynamic-linker"]\n', + "rustflags:codegen linker", + ) + + def test_unrelated_linker_option_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--as-needed"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_runtime_search_path_authority_contract.py b/tests/test_browser_session_linker_runtime_search_path_authority_contract.py new file mode 100644 index 000000000..de8f51b53 --- /dev/null +++ b/tests/test_browser_session_linker_runtime_search_path_authority_contract.py @@ -0,0 +1,85 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionLinkerRuntimeSearchPathAuthorityContractTests(unittest.TestCase): + """Keep ELF runtime/link-time shared-library search paths in reviewed provenance.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_rpath_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-rpath=tools/runtime-libs"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_long_rpath_link_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=--for-linker=--rpath-link=tools/link-libs\"]\n", + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_long_rpath_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-arg=-Wl,--rpath=tools/doc-runtime-libs"]\n', + "rustdocflags:codegen linker", + ) + + def test_target_rustdocflags_split_rpath_link_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustdocflags = [\"-C\", \"link-args=-Xlinker -rpath-link -Xlinker tools/doc-link-libs\"]\n", + "rustdocflags:codegen linker", + ) + + def test_doctest_forwarded_rpath_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,-rpath=tools/doctest-runtime-libs"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_new_dtags_toggle_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--enable-new-dtags"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_script_provenance_contract.py b/tests/test_browser_session_linker_script_provenance_contract.py new file mode 100644 index 000000000..a9dfa1620 --- /dev/null +++ b/tests/test_browser_session_linker_script_provenance_contract.py @@ -0,0 +1,76 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLinkerScriptProvenanceContractTests(unittest.TestCase): + """Keep GNU linker-script input authority inside the reviewed Browser Session build boundary.""" + + def _workspace_with_flags(self, rustflags: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + tools = root / "tools" + tools.mkdir() + (tools / "review-bypass.ld").write_text( + "INPUT(tools/review-bypass-object.o)\n", + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text( + f'[build]\nrustflags = {rustflags}\n', + encoding="utf-8", + ) + return root + + def test_repository_driver_linker_script_fails_closed(self) -> None: + root = self._workspace_with_flags( + '["-C", "link-arg=-Ttools/review-bypass.ld"]' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_forwarded_linker_script_fails_closed(self) -> None: + root = self._workspace_with_flags( + '["-C", "link-arg=-Wl,--script=tools/review-bypass.ld"]' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_xlinker_script_fails_closed(self) -> None: + root = self._workspace_with_flags( + '["-C", "link-args=-Xlinker -T -Xlinker tools/review-bypass.ld"]' + ) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_unrelated_driver_link_argument_remains_allowed(self) -> None: + root = self._workspace_with_flags('["-C", "link-arg=-pthread"]') + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_section_ordering_file_authority_contract.py b/tests/test_browser_session_linker_section_ordering_file_authority_contract.py new file mode 100644 index 000000000..bc735af2b --- /dev/null +++ b/tests/test_browser_session_linker_section_ordering_file_authority_contract.py @@ -0,0 +1,79 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionLinkerSectionOrderingFileAuthorityContractTests(unittest.TestCase): + """Keep section-ordering scripts outside repository-owned Browser Session linker authority.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_section_ordering_file_equals_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--section-ordering-file=tools/review-bypass-order.ld"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_single_dash_section_ordering_file_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=--for-linker=-section-ordering-file=tools/review-bypass-order.ld\"]\n", + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_section_ordering_file_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-arg=-Wl,--section-ordering-file=tools/review-bypass-order.ld"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_forwarded_section_ordering_file_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,--section-ordering-file=tools/review-bypass-order.ld"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_non_script_linker_hardening_option_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-z,relro"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_symbol_policy_input_authority_contract.py b/tests/test_browser_session_linker_symbol_policy_input_authority_contract.py new file mode 100644 index 000000000..4942a3a10 --- /dev/null +++ b/tests/test_browser_session_linker_symbol_policy_input_authority_contract.py @@ -0,0 +1,79 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionLinkerSymbolPolicyInputAuthorityContractTests(unittest.TestCase): + """Keep external linker symbol-policy files inside reviewed Browser Session provenance.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_version_script_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--version-script=tools/review-bypass.map"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_dynamic_list_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=--for-linker=--dynamic-list=tools/review-bypass.dynamic\"]\n", + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_retain_symbols_file_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-arg=-Wl,--retain-symbols-file=tools/review-bypass.symbols"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_forwarded_export_dynamic_symbol_list_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,--export-dynamic-symbol-list=tools/review-bypass.exports"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_inline_symbol_selection_without_external_file_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--export-dynamic-symbol=originweave_*"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_sysroot_input_authority_contract.py b/tests/test_browser_session_linker_sysroot_input_authority_contract.py new file mode 100644 index 000000000..93b907c0c --- /dev/null +++ b/tests/test_browser_session_linker_sysroot_input_authority_contract.py @@ -0,0 +1,79 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionLinkerSysrootInputAuthorityContractTests(unittest.TestCase): + """Keep linker sysroot selection outside repository-owned Browser Session builds.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_gnu_linker_sysroot_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--sysroot=tools/review-bypass-sysroot"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_for_linker_sysroot_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=--for-linker=--sysroot=tools/review-bypass-sysroot\"]\n", + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_xlinker_sysroot_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-args=-Xlinker --sysroot=tools/review-bypass-sysroot"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_forwarded_linker_sysroot_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,--sysroot=tools/review-bypass-sysroot"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_non_input_linker_hardening_option_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-z,relro"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_linker_toolchain_selection_contract.py b/tests/test_browser_session_linker_toolchain_selection_contract.py new file mode 100644 index 000000000..0cd0f7911 --- /dev/null +++ b/tests/test_browser_session_linker_toolchain_selection_contract.py @@ -0,0 +1,70 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLinkerToolchainSelectionContractTests(unittest.TestCase): + """Keep rustc-managed linker and auxiliary binary selection inside the reviewed build boundary.""" + + def _workspace_with_flags(self, rustflags: str, *, target_scoped: bool = False) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + table = '[target.x86_64-unknown-linux-gnu]' if target_scoped else '[build]' + (cargo / "config.toml").write_text( + f'{table}\nrustflags = {rustflags}\n', + encoding="utf-8", + ) + return root + + def _assert_fails_closed(self, rustflags: str, *, target_scoped: bool = False) -> None: + root = self._workspace_with_flags(rustflags, target_scoped=target_scoped) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_repository_link_self_contained_linker_selection_fails_closed(self) -> None: + self._assert_fails_closed('["-C", "link-self-contained=+linker"]') + + def test_repository_target_linker_features_selection_fails_closed(self) -> None: + self._assert_fails_closed( + '["--codegen=linker-features=+lld"]', + target_scoped=True, + ) + + def test_repository_linker_flavor_selection_fails_closed(self) -> None: + self._assert_fails_closed('["-C", "linker-flavor=ld.lld"]') + + def test_repository_dlltool_executable_selection_fails_closed(self) -> None: + self._assert_fails_closed('["--codegen=dlltool=tools/review-bypass-dlltool"]') + + def test_unrelated_codegen_option_remains_allowed(self) -> None: + root = self._workspace_with_flags('["-C", "debuginfo=1"]') + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_lld_cmse_import_library_authority_contract.py b/tests/test_browser_session_lld_cmse_import_library_authority_contract.py new file mode 100644 index 000000000..df6de8b36 --- /dev/null +++ b/tests/test_browser_session_lld_cmse_import_library_authority_contract.py @@ -0,0 +1,36 @@ +import importlib.util +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLldCmseImportLibraryAuthorityContractTests(unittest.TestCase): + """Keep pre-existing LLD CMSE import libraries inside reviewed linker provenance.""" + + def test_joined_in_implib_fails_closed(self) -> None: + rustflags = ["-C", "link-arg=-Wl,--in-implib=tools/previous-cmse-import.o"] + self.assertTrue(authority._flags_select_linker(rustflags)) + + def test_rustdoc_doctest_forwarding_cannot_select_in_implib(self) -> None: + rustdocflags = [ + "--doctest-build-arg=-C", + "--doctest-build-arg=link-arg=-Wl,--in-implib=tools/previous-cmse-import.o", + ] + self.assertTrue(authority._flags_select_rustdoc_doctest_compiler_authority(rustdocflags)) + + def test_out_implib_output_path_remains_allowed(self) -> None: + rustflags = ["-C", "link-arg=-Wl,--out-implib=target/cmse-import.o"] + self.assertFalse(authority._flags_select_linker(rustflags)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_lld_dtlto_argument_authority_contract.py b/tests/test_browser_session_lld_dtlto_argument_authority_contract.py new file mode 100644 index 000000000..a53f0cc13 --- /dev/null +++ b/tests/test_browser_session_lld_dtlto_argument_authority_contract.py @@ -0,0 +1,50 @@ +import importlib.util +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLldDtltoArgumentAuthorityContractTests(unittest.TestCase): + """Keep DTLTO subprocess arguments inside reviewed linker execution provenance.""" + + def test_dtlto_subprocess_argument_forwarding_fails_closed(self) -> None: + hostile_options = ( + "--thinlto-distributor-arg=tools/unreviewed-distributor.py", + "--thinlto-remote-compiler-prepend-arg=-fplugin=tools/unreviewed-pass.so", + "--thinlto-remote-compiler-arg=-fplugin=tools/unreviewed-pass.so", + ) + for option in hostile_options: + with self.subTest(option=option): + rustflags = ["-C", f"link-arg=-Wl,{option}"] + self.assertTrue(authority._flags_select_linker(rustflags)) + + def test_separated_dtlto_subprocess_argument_fails_closed(self) -> None: + rustflags = [ + "-C", + "link-arg=-Wl,--thinlto-remote-compiler-arg,--target=riscv64-unknown-linux-gnu", + ] + self.assertTrue(authority._flags_select_linker(rustflags)) + + def test_rustdoc_doctest_forwarding_cannot_select_dtlto_subprocess_arguments(self) -> None: + rustdocflags = [ + "--doctest-build-arg=-C", + "--doctest-build-arg=link-arg=-Wl,--thinlto-remote-compiler-arg=-fplugin=tools/unreviewed-pass.so", + ] + self.assertTrue(authority._flags_select_rustdoc_doctest_compiler_authority(rustdocflags)) + + def test_typed_linker_control_remains_allowed(self) -> None: + rustflags = ["-C", "link-arg=-Wl,-z,relro"] + self.assertFalse(authority._flags_select_linker(rustflags)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_lld_error_handler_authority_contract.py b/tests/test_browser_session_lld_error_handler_authority_contract.py new file mode 100644 index 000000000..658d9d0ab --- /dev/null +++ b/tests/test_browser_session_lld_error_handler_authority_contract.py @@ -0,0 +1,79 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionLldErrorHandlerAuthorityContractTests(unittest.TestCase): + """Keep linker-selected error-handler executables outside reviewed Browser Session builds.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_lld_error_handler_script_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--error-handling-script=tools/review-bypass-handler"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_split_lld_error_handler_script_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=-Wl,--error-handling-script,tools/review-bypass-handler\"]\n", + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_lld_error_handler_script_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-arg=-Wl,--error-handling-script=tools/review-bypass-handler"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_forwarded_lld_error_handler_script_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,--error-handling-script=tools/review-bypass-handler"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_non_executable_linker_policy_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--as-needed"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_lld_layout_profile_input_authority_contract.py b/tests/test_browser_session_lld_layout_profile_input_authority_contract.py new file mode 100644 index 000000000..03f2a23e0 --- /dev/null +++ b/tests/test_browser_session_lld_layout_profile_input_authority_contract.py @@ -0,0 +1,56 @@ +import importlib.util +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLldLayoutProfileInputAuthorityContractTests(unittest.TestCase): + """Keep repository-selected LLD layout/profile files inside reviewed provenance.""" + + def test_lld_layout_and_profile_file_inputs_fail_closed(self) -> None: + hostile = ( + "--call-graph-ordering-file=tools/callgraph.order", + "-call-graph-ordering-file=tools/callgraph.order", + "--irpgo-profile=tools/startup.profdata", + "--symbol-ordering-file=tools/symbols.order", + "--lto-sample-profile=tools/sample.prof", + "--plugin-opt=sample-profile=tools/sample.prof", + "-plugin-opt=sample-profile=tools/sample.prof", + "--lto-cs-profile-file=tools/context-sensitive.profdata", + "--plugin-opt=cs-profile-path=tools/context-sensitive.profdata", + "-plugin-opt=cs-profile-path=tools/context-sensitive.profdata", + ) + for linker_option in hostile: + with self.subTest(linker_option=linker_option): + rustflags = ["-C", f"link-arg=-Wl,{linker_option}"] + self.assertTrue(authority._flags_select_linker(rustflags)) + + def test_rustdoc_doctest_forwarding_cannot_select_lld_profile_file(self) -> None: + rustdocflags = [ + "--doctest-build-arg=-C", + "--doctest-build-arg=link-arg=-Wl,--lto-sample-profile=tools/sample.prof", + ] + self.assertTrue(authority._flags_select_rustdoc_doctest_compiler_authority(rustdocflags)) + + def test_rustdoc_doctest_forwarding_cannot_select_lld_context_sensitive_profile_file(self) -> None: + rustdocflags = [ + "--doctest-build-arg=-C", + "--doctest-build-arg=link-arg=-Wl,--lto-cs-profile-file=tools/context-sensitive.profdata", + ] + self.assertTrue(authority._flags_select_rustdoc_doctest_compiler_authority(rustdocflags)) + + def test_typed_linker_control_remains_allowed(self) -> None: + self.assertFalse(authority._flags_select_linker(["-C", "link-arg=-Wl,-z,relro"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_lld_mllvm_authority_contract.py b/tests/test_browser_session_lld_mllvm_authority_contract.py new file mode 100644 index 000000000..33dd740b5 --- /dev/null +++ b/tests/test_browser_session_lld_mllvm_authority_contract.py @@ -0,0 +1,103 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionLldMllvmAuthorityContractTests(unittest.TestCase): + """Keep repository-selected LLD-to-LLVM option forwarding outside the reviewed build TCB.""" + + def _assert_fails_closed(self, config_text: str, marker: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, marker): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_lld_mllvm_equals_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--mllvm=-debug-pass=Structure"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_lld_mllvm_split_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=-Wl,-mllvm,-print-after-all\"]\n", + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_lld_mllvm_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-arg=-Wl,--mllvm=-print-after-all"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_build_arg_lld_mllvm_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,--mllvm=-print-after-all"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_build_rustflags_lld_plugin_opt_llvm_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Wl,--plugin-opt=-debug-pass=Structure"]\n', + "rustflags:codegen linker", + ) + + def test_target_rustflags_lld_single_dash_plugin_opt_llvm_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=-Wl,-plugin-opt=-print-after-all\"]\n", + "rustflags:codegen linker", + ) + + def test_build_rustdocflags_lld_plugin_opt_llvm_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-C", "link-arg=-Wl,--plugin-opt=-print-after-all"]\n', + "rustdocflags:codegen linker", + ) + + def test_doctest_build_arg_lld_plugin_opt_llvm_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=link-arg=-Wl,--plugin-opt=-print-after-all"]\n', + "rustdocflags:doctest compiler authority", + ) + + def test_typed_linker_control_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "link-arg=-Wl,-z,relro"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_lld_thinlto_cache_authority_contract.py b/tests/test_browser_session_lld_thinlto_cache_authority_contract.py new file mode 100644 index 000000000..dc0b5553a --- /dev/null +++ b/tests/test_browser_session_lld_thinlto_cache_authority_contract.py @@ -0,0 +1,39 @@ +import importlib.util +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLldThinLtoCacheAuthorityContractTests(unittest.TestCase): + """Keep ThinLTO cached object inputs inside reviewed linker provenance.""" + + def test_joined_thinlto_cache_dir_fails_closed(self) -> None: + rustflags = [ + "-C", + "link-arg=-Wl,--thinlto-cache-dir=tools/unreviewed-thinlto-cache", + ] + self.assertTrue(authority._flags_select_linker(rustflags)) + + def test_rustdoc_doctest_forwarding_cannot_select_thinlto_cache_dir(self) -> None: + rustdocflags = [ + "--doctest-build-arg=-C", + "--doctest-build-arg=link-arg=-Wl,--thinlto-cache-dir=tools/unreviewed-thinlto-cache", + ] + self.assertTrue(authority._flags_select_rustdoc_doctest_compiler_authority(rustdocflags)) + + def test_typed_linker_control_remains_allowed(self) -> None: + rustflags = ["-C", "link-arg=-Wl,-z,relro"] + self.assertFalse(authority._flags_select_linker(rustflags)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_llvm_args_authority_contract.py b/tests/test_browser_session_llvm_args_authority_contract.py new file mode 100644 index 000000000..514fbf58e --- /dev/null +++ b/tests/test_browser_session_llvm_args_authority_contract.py @@ -0,0 +1,80 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionLlvmArgsAuthorityContractTests(unittest.TestCase): + """Keep repository-selected direct LLVM option authority outside the reviewed build TCB.""" + + def _assert_fails_closed(self, config_text: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_llvm_args_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-C", "llvm-args=-debug-pass=Structure"]\n' + ) + + def test_target_rustflags_compact_llvm_args_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-Cllvm-args=-print-after-all\"]\n" + ) + + def test_profile_rustflags_long_llvm_args_fails_closed(self) -> None: + self._assert_fails_closed( + '[unstable]\nprofile-rustflags = true\n\n[profile.release]\nrustflags = ["--codegen=llvm-args=-debug-pass=Structure"]\n' + ) + + def test_build_rustdocflags_llvm_args_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--codegen", "llvm-args=-debug-pass=Structure"]\n' + ) + + def test_doctest_build_arg_llvm_args_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=llvm-args=-debug-pass=Structure"]\n' + ) + + def test_typed_codegen_option_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-C", "opt-level=2"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_llvm_plugin_authority_contract.py b/tests/test_browser_session_llvm_plugin_authority_contract.py new file mode 100644 index 000000000..d260f05ba --- /dev/null +++ b/tests/test_browser_session_llvm_plugin_authority_contract.py @@ -0,0 +1,74 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionLlvmPluginAuthorityContractTests(unittest.TestCase): + """Reject repository-selected rustc LLVM pass plugins from Git-owned Cargo flags.""" + + def _workspace(self, config_text: str, *, profile_manifest: str = "") -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n' + profile_manifest, + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def _assert_plugin_fails_closed(self, config_text: str, *, profile_manifest: str = "") -> None: + root = self._workspace(config_text, profile_manifest=profile_manifest) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_split_llvm_plugin_fails_closed(self) -> None: + self._assert_plugin_fails_closed( + '[build]\nrustflags = ["-Z", "llvm-plugins=tools/review-bypass-pass.so"]\n' + ) + + def test_target_rustflags_compact_llvm_plugin_fails_closed(self) -> None: + self._assert_plugin_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-Zllvm-plugins=tools/review-bypass-pass.so\"]\n" + ) + + def test_config_profile_rustflags_llvm_plugin_fails_closed(self) -> None: + self._assert_plugin_fails_closed( + '[unstable]\nprofile-rustflags = true\n\n[profile.release]\nrustflags = ["-Z", "llvm-plugins=tools/review-bypass-pass.so"]\n' + ) + + def test_root_profile_rustflags_llvm_plugin_fails_closed(self) -> None: + self._assert_plugin_fails_closed( + '', + profile_manifest='\n[profile.release]\nrustflags = ["-Zllvm-plugins=tools/review-bypass-pass.so"]\n', + ) + + def test_non_plugin_rustflags_remain_allowed(self) -> None: + root = self._workspace( + '[build]\nrustflags = ["-C", "opt-level=2", "--cfg", "originweave_reviewed"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_native_library_input_contract.py b/tests/test_browser_session_native_library_input_contract.py new file mode 100644 index 000000000..b0e3b3335 --- /dev/null +++ b/tests/test_browser_session_native_library_input_contract.py @@ -0,0 +1,86 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +COMPILER_AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location( + "browser_session_cargo_compiler_authority_contract", + COMPILER_AUTHORITY_TEST, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionNativeLibraryInputContractTests(unittest.TestCase): + """Keep Git-owned native library/search-path inputs inside the reviewed Browser Session TCB.""" + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def _assert_input_override_fails_closed(self, config_text: str) -> None: + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_git_owned_native_library_search_path_fails_closed(self) -> None: + self._assert_input_override_fails_closed( + '[build]\nrustflags = ["-L", "native=tools/review-bypass-native"]\n' + ) + + def test_git_owned_native_library_link_request_fails_closed(self) -> None: + self._assert_input_override_fails_closed( + '[build]\nrustflags = ["-l", "static:+whole-archive=review_bypass_native"]\n' + ) + + def test_codegen_link_arg_native_library_search_path_fails_closed(self) -> None: + self._assert_input_override_fails_closed( + '[build]\nrustflags = ["-C", "link-arg=-Ltools/review-bypass-native"]\n' + ) + + def test_codegen_link_args_native_library_search_path_fails_closed(self) -> None: + self._assert_input_override_fails_closed( + '[build]\nrustflags = ["--codegen", "link-args=-L tools/review-bypass-native"]\n' + ) + + def test_target_codegen_link_arg_native_library_request_fails_closed(self) -> None: + self._assert_input_override_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"link-arg=-lreview_bypass_native\"]\n" + ) + + def test_target_codegen_link_args_native_library_request_fails_closed(self) -> None: + self._assert_input_override_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"--codegen=link-args=-l review_bypass_native\"]\n" + ) + + def test_unrelated_codegen_flag_remains_allowed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustflags = ["-C", "debuginfo=1"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_navigation_owner_surface_contract.py b/tests/test_browser_session_navigation_owner_surface_contract.py new file mode 100644 index 000000000..d06002c9f --- /dev/null +++ b/tests/test_browser_session_navigation_owner_surface_contract.py @@ -0,0 +1,109 @@ +"""Owner-side repository contracts for Browser Session navigation authority.""" + +from __future__ import annotations + +import pathlib +import re +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE_PATH = ROOT / "crates/originweave-browser-session/src/browser_session.rs" + + +class BrowserSessionNavigationOwnerSurfaceContractTests(unittest.TestCase): + """Pin the navigation surface in the production owner, independent of stacked acceptance PRs.""" + + def setUp(self) -> None: + """Read the Browser Session owner source once for each contract assertion.""" + + self.source = SOURCE_PATH.read_text(encoding="utf-8") + + def assert_source_pattern(self, pattern: str) -> None: + """Require a structural source token without depending on rustfmt whitespace.""" + + self.assertRegex(self.source, re.compile(pattern, re.MULTILINE | re.DOTALL)) + + def test_navigation_capabilities_are_owner_issued_and_not_raw_id_constructible(self) -> None: + """Navigation settlement must remain an opaque Browser Session capability.""" + + for pattern in ( + r"pub\s+struct\s+NavigationSettlementAuthority\s*\{", + r"pub\s+enum\s+NavigationTerminationOutcome\s*\{", + r"pub\s+fn\s+record_observed_navigation\s*\(", + r"pub\s+fn\s+record_observed_navigation_committed\s*\(", + r"pub\s+fn\s+record_observed_navigation_settled\s*\(", + r"pub\s+fn\s+record_observed_navigation_terminated\s*\(", + r"pub\s+fn\s+record_observed_navigation_download_started\s*\(", + r"pub\s+fn\s+reestablish_presentation_authority\s*\(", + r"pub\s+fn\s+destroy_owned_disposable_context\s*\(", + ): + self.assert_source_pattern(pattern) + + witness_match = re.search( + r"pub\s+struct\s+NavigationSettlementAuthority\s*\{(?P.*?)\n\}", + self.source, + flags=re.DOTALL, + ) + self.assertIsNotNone(witness_match) + witness_body = witness_match.group("body") if witness_match else "" + self.assertNotRegex(witness_body, r"(?m)^\s*pub(?:\([^)]*\))?\s+") + self.assertRegex(witness_body, r"navigation_generation\s*:\s*u64") + self.assertRegex(witness_body, r"context_epoch\s*:\s*BrowserContextEpoch") + self.assertNotRegex( + self.source, + r"#\s*\[\s*derive\s*\([^\]]*\bDefault\b[^\]]*\)\s*\]" + r"(?:(?:\s*#\s*\[[^\]]*\])|\s)*" + r"pub\s+struct\s+NavigationSettlementAuthority\b", + "Default would let raw callers fabricate a settlement witness", + ) + self.assertNotRegex( + self.source, + r"\bimpl(?:\s*<[^{};]*>)?\s+" + r"(?:Default|From\s*<[^{};]+>|TryFrom\s*<[^{};]+>)\s+for\s+" + r"NavigationSettlementAuthority\b", + "conversion/default traits must not expose a caller-mintable witness path", + ) + self.assertNotRegex( + self.source, + r"impl\s+NavigationSettlementAuthority\b", + "The settlement witness is intentionally opaque and has no inherent mint/read surface.", + ) + + def test_navigation_state_machine_keeps_liveness_separate_from_presentation_epoch(self) -> None: + """Navigation generations close independently before explicit presentation re-establishment.""" + + for pattern in ( + r"PresentationNavigationState\s*::\s*Pending", + r"PresentationNavigationState\s*::\s*Eligible", + r"reserve_navigation_generation\s*\(", + r"mark_observed_navigation_committed\s*\(", + r"close_observed_navigation\s*\(", + r"reestablish_presentation_authority_for_context\s*\(", + r"reserve_epoch\s*\(\s*&mut\s+self\.next_epoch\s*\)", + ): + self.assert_source_pattern(pattern) + + bound_start = re.search( + r"impl\s*<\s*P\s*:\s*DisposableContextPort\s*>\s*BoundBrowserSession\s*<\s*P\s*>\s*\{", + self.source, + ) + operation_start = re.search( + r"impl\s*<\s*P\s*:\s*AuthorizedContextOperationPort\s*>\s*BoundBrowserSession\s*<\s*P\s*>\s*\{", + self.source, + ) + self.assertIsNotNone(bound_start) + self.assertIsNotNone(operation_start) + self.assertLess(bound_start.start(), operation_start.start()) + bound_surface = self.source[bound_start.start() : operation_start.start()] + compact_surface = "".join(bound_surface.split()) + self.assertIn( + "self.session.begin_observed_navigation(incarnation,browsing_context,context_epoch)", + compact_surface, + ) + self.assertGreaterEqual(compact_surface.count("self.session.close_observed_navigation(authority)"), 3) + self.assertRegex(bound_surface, r"NavigationTerminationOutcome\s*::\s*Aborted") + self.assertRegex(bound_surface, r"NavigationTerminationOutcome\s*::\s*Failed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_path_meta_lexical_contract.py b/tests/test_browser_session_path_meta_lexical_contract.py new file mode 100644 index 000000000..2086269f4 --- /dev/null +++ b/tests/test_browser_session_path_meta_lexical_contract.py @@ -0,0 +1,51 @@ +import importlib.util +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE_INDIRECTION_TEST = ROOT / "tests/test_browser_session_rust_source_indirection_contract.py" + +spec = importlib.util.spec_from_file_location( + "browser_session_rust_source_indirection_contract", + SOURCE_INDIRECTION_TEST, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Rust source-indirection contract") +source_indirection = importlib.util.module_from_spec(spec) +spec.loader.exec_module(source_indirection) + + +class BrowserSessionPathMetaLexicalContractTests(unittest.TestCase): + """Keep path-attribute authority tied to lexical Rust meta items, not data text.""" + + def test_doc_string_path_text_is_not_path_meta(self) -> None: + self.assertFalse( + source_indirection._has_path_meta('doc = "path = \\"review_bypass.rs\\""') + ) + + def test_raw_doc_string_path_text_is_not_path_meta(self) -> None: + self.assertFalse( + source_indirection._has_path_meta('doc = r#"path = \\"review_bypass.rs\\""#') + ) + + def test_comment_path_text_is_not_path_meta(self) -> None: + self.assertFalse( + source_indirection._has_path_meta( + 'cfg_attr(unix, /* path = "review_bypass.rs" */ allow(dead_code))' + ) + ) + + def test_nested_cfg_attr_path_meta_remains_authority(self) -> None: + self.assertTrue( + source_indirection._has_path_meta( + 'cfg_attr(unix, path /* reviewed trivia */ = "unix_adapter.rs")' + ) + ) + + def test_raw_identifier_path_meta_remains_authority(self) -> None: + self.assertTrue(source_indirection._has_path_meta('r#path = "raw_identifier.rs"')) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_pgo_profile_input_authority_contract.py b/tests/test_browser_session_pgo_profile_input_authority_contract.py new file mode 100644 index 000000000..2ba60a0e1 --- /dev/null +++ b/tests/test_browser_session_pgo_profile_input_authority_contract.py @@ -0,0 +1,80 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionPgoProfileInputAuthorityContractTests(unittest.TestCase): + """Keep repository-selected PGO data inside reviewed compiler-input provenance.""" + + def _assert_fails_closed(self, config_text: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustflags_profile_use_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustflags = ["-Cprofile-use=tools/review-bypass.profdata"]\n' + ) + + def test_target_rustflags_profile_sample_use_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustflags = [\"-C\", \"profile-sample-use=tools/review-bypass.prof\"]\n" + ) + + def test_profile_rustflags_profile_use_fails_closed(self) -> None: + self._assert_fails_closed( + '[unstable]\nprofile-rustflags = true\n\n[profile.release]\nrustflags = ["--codegen=profile-use=tools/review-bypass.profdata"]\n' + ) + + def test_build_rustdocflags_profile_use_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--codegen=profile-use=tools/review-bypass.profdata"]\n' + ) + + def test_doctest_build_arg_profile_use_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--doctest-build-arg=-C", "--doctest-build-arg=profile-use=tools/review-bypass.profdata"]\n' + ) + + def test_profile_generate_output_remains_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustflags = ["-Cprofile-generate=target/pgo-data"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_production_source_containment_contract.py b/tests/test_browser_session_production_source_containment_contract.py new file mode 100644 index 000000000..e5bd8e8d1 --- /dev/null +++ b/tests/test_browser_session_production_source_containment_contract.py @@ -0,0 +1,78 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +BOUNDARY_TEST = ROOT / "tests/test_browser_session_trusted_adapter_boundary.py" + +spec = importlib.util.spec_from_file_location("browser_session_trusted_adapter_boundary", BOUNDARY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session trusted-adapter boundary contract") +boundary = importlib.util.module_from_spec(spec) +spec.loader.exec_module(boundary) + + +class BrowserSessionProductionSourceContainmentContractTests(unittest.TestCase): + """Keep every Cargo production source inside exact-head repository provenance.""" + + def test_current_production_source_closure_is_canonical_and_nonempty(self) -> None: + reviewed = boundary._workspace_production_sources(ROOT) + self.assertGreater(len(reviewed), 0) + self.assertTrue(all(source.is_file() for source in reviewed)) + + def test_default_rust_source_symlink_outside_repository_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + sandbox = pathlib.Path(directory) + root = sandbox / "workspace" + external = sandbox / "external-lifecycle-adapter.rs" + root.mkdir() + external.write_text( + "use originweave_browser_session::DisposableContextPort;\n", + encoding="utf-8", + ) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + source = adapter / "src/lib.rs" + source.symlink_to(external) + + with self.assertRaisesRegex( + AssertionError, + "production Cargo source escapes repository review root", + ): + boundary._workspace_production_sources(root) + + def test_dangling_default_rust_source_symlink_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + source = adapter / "src/lib.rs" + source.symlink_to(adapter / "missing-generated.rs") + + with self.assertRaisesRegex( + AssertionError, + "production Cargo source is missing", + ): + boundary._workspace_production_sources(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_profile_rustflags_authority_contract.py b/tests/test_browser_session_profile_rustflags_authority_contract.py new file mode 100644 index 000000000..6e10e7b35 --- /dev/null +++ b/tests/test_browser_session_profile_rustflags_authority_contract.py @@ -0,0 +1,78 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionProfileRustflagsAuthorityContractTests(unittest.TestCase): + """Keep profile-selected rustc arguments inside the reviewed Cargo authority boundary.""" + + def _workspace(self, *, profile_text: str, config_text: str | None = None) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + 'cargo-features = ["profile-rustflags"]\n\n' + '[workspace]\n' + 'members = ["adapter"]\n' + 'resolver = "3"\n\n' + f"{profile_text}", + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + if config_text is not None: + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def test_root_manifest_profile_rustflags_external_input_fails_closed(self) -> None: + root = self._workspace( + profile_text=( + '[profile.release]\n' + 'rustflags = ["--extern", "review_bypass=tools/libreview_bypass.rlib"]\n' + ) + ) + with self.assertRaisesRegex(AssertionError, "profile_rustflag_authority"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_cargo_config_profile_rustflags_linker_selection_fails_closed(self) -> None: + root = self._workspace( + profile_text='[profile.release]\nopt-level = 2\n', + config_text=( + '[unstable]\nprofile-rustflags = true\n\n' + '[profile.release]\n' + 'rustflags = ["-C", "linker=tools/review-bypass-linker"]\n' + ), + ) + with self.assertRaisesRegex(AssertionError, "profile_rustflag_authority"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_non_authority_profile_rustflags_remain_allowed(self) -> None: + root = self._workspace( + profile_text=( + '[profile.release]\n' + 'rustflags = ["-C", "opt-level=2", "--cfg", "originweave_reviewed"]\n' + ) + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_raw_identifier_include_contract.py b/tests/test_browser_session_raw_identifier_include_contract.py new file mode 100644 index 000000000..23935ff93 --- /dev/null +++ b/tests/test_browser_session_raw_identifier_include_contract.py @@ -0,0 +1,32 @@ +import importlib.util +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE_INDIRECTION_TEST = ROOT / "tests/test_browser_session_rust_source_indirection_contract.py" + +spec = importlib.util.spec_from_file_location( + "browser_session_rust_source_indirection_contract", + SOURCE_INDIRECTION_TEST, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Rust source-indirection contract") +source_indirection = importlib.util.module_from_spec(spec) +spec.loader.exec_module(source_indirection) + + +class BrowserSessionRawIdentifierIncludeContractTests(unittest.TestCase): + """Keep raw-identifier macro spelling inside the include! provenance stop.""" + + def test_raw_identifier_include_macro_fails_closed(self) -> None: + self.assertTrue( + source_indirection._has_include_macro( + 'r#include!("../generated_adapter.rs");\n' + ), + "Rust raw identifiers preserve the underlying macro identifier and must not bypass include! provenance", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_recovery_impl_surface_contract.py b/tests/test_browser_session_recovery_impl_surface_contract.py new file mode 100644 index 000000000..027f9fdba --- /dev/null +++ b/tests/test_browser_session_recovery_impl_surface_contract.py @@ -0,0 +1,137 @@ +"""Defense-in-depth contracts for recovery request impl classification.""" + +from __future__ import annotations + +import importlib.util +import pathlib +import re +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +BASE_CONTRACT = ROOT / "tests/test_browser_session_recovery_operation_contract.py" +RECOVERY_SOURCE = ROOT / "crates/originweave-browser-session/src/recovery.rs" + +_spec = importlib.util.spec_from_file_location("_recovery_operation_contract", BASE_CONTRACT) +if _spec is None or _spec.loader is None: + raise RuntimeError("cannot load recovery operation contract helper") +_base_contract = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_base_contract) +_rust_impl_headers = _base_contract._rust_impl_headers + + +def _is_inherent_impl_header(header: str) -> bool: + """Classify impl kind using only top-level Rust tokens before a where clause.""" + + if not re.match(r"^impl\b", header): + return False + + angle_depth = 0 + paren_depth = 0 + bracket_depth = 0 + brace_depth = 0 + cursor = len("impl") + length = len(header) + + while cursor < length: + char = header[cursor] + if char == "<": + angle_depth += 1 + cursor += 1 + continue + if char == ">" and angle_depth: + angle_depth -= 1 + cursor += 1 + continue + if char == "(": + paren_depth += 1 + cursor += 1 + continue + if char == ")" and paren_depth: + paren_depth -= 1 + cursor += 1 + continue + if char == "[": + bracket_depth += 1 + cursor += 1 + continue + if char == "]" and bracket_depth: + bracket_depth -= 1 + cursor += 1 + continue + if char == "{": + brace_depth += 1 + cursor += 1 + continue + if char == "}" and brace_depth: + brace_depth -= 1 + cursor += 1 + continue + + top_level = not (angle_depth or paren_depth or bracket_depth or brace_depth) + if top_level and (char.isalpha() or char == "_"): + end = cursor + 1 + while end < length and (header[end].isalnum() or header[end] == "_"): + end += 1 + token = header[cursor:end] + if token == "for": + return False + if token == "where": + return True + cursor = end + continue + cursor += 1 + + return True + + +class BrowserSessionRecoveryImplSurfaceContractTests(unittest.TestCase): + """Prevent nested token text from hiding a second inherent request impl.""" + + def test_current_request_has_one_inherent_impl(self) -> None: + """Trait classification must depend on top-level syntax, not substring text.""" + + recovery_source = RECOVERY_SOURCE.read_text(encoding="utf-8") + request_headers = [ + header + for header in _rust_impl_headers(recovery_source) + if "RecoveryContextOperationRequest" in header + ] + inherent_headers = [ + header for header in request_headers if _is_inherent_impl_header(header) + ] + self.assertEqual( + inherent_headers, + ["impl RecoveryContextOperationRequest"], + "request accessors must remain the sole inherent impl", + ) + + def test_nested_macro_for_tokens_do_not_disguise_inherent_impl(self) -> None: + """A legal macro token tree containing `for Type` is not a trait impl.""" + + sample = r''' +macro_rules! marker { + ($($tokens:tt)*) => { 2usize }; +} +impl RecoveryContextOperationRequest +where + O: RecoveryMarker<{ marker! { for RecoveryContextOperationRequest } }>, +{ + pub fn from_macro(operation: O) -> Self { todo!() } +} +impl RecoveryContextOperationPort for RecoveryContextOperationRequest { + fn execute(&mut self) { todo!() } +} +''' + request_headers = [ + header + for header in _rust_impl_headers(sample) + if "RecoveryContextOperationRequest" in header + ] + self.assertEqual(len(request_headers), 2) + self.assertTrue(_is_inherent_impl_header(request_headers[0])) + self.assertFalse(_is_inherent_impl_header(request_headers[1])) + self.assertIn(" for RecoveryContextOperationRequest", request_headers[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_recovery_operation_contract.py b/tests/test_browser_session_recovery_operation_contract.py new file mode 100644 index 000000000..527cf63b2 --- /dev/null +++ b/tests/test_browser_session_recovery_operation_contract.py @@ -0,0 +1,383 @@ +"""Repository contracts for Browser Session recovery-only adapter custody.""" + +from __future__ import annotations + +import pathlib +import re +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +CRATE = ROOT / "crates/originweave-browser-session" + + +def _rust_impl_headers(source: str) -> list[str]: + """Return Rust impl headers while ignoring nested delimiters and literal/comment text.""" + + headers: list[str] = [] + length = len(source) + index = 0 + + def starts_lifetime(position: int) -> bool: + """Distinguish Rust lifetimes/labels from quoted character literals.""" + + if source[position] != "'" or position + 1 >= length: + return False + cursor = position + 1 + if not (source[cursor].isalpha() or source[cursor] == "_"): + return False + cursor += 1 + while cursor < length and (source[cursor].isalnum() or source[cursor] == "_"): + cursor += 1 + return cursor >= length or source[cursor] != "'" + + def skip_non_code(position: int) -> int: + if source.startswith("//", position): + newline = source.find("\n", position + 2) + return length if newline < 0 else newline + 1 + if source.startswith("/*", position): + depth = 1 + cursor = position + 2 + while cursor < length and depth: + if source.startswith("/*", cursor): + depth += 1 + cursor += 2 + elif source.startswith("*/", cursor): + depth -= 1 + cursor += 2 + else: + cursor += 1 + return cursor + + raw = re.match(r"(?:br|r)(?P#{0,255})\"", source[position:]) + if raw: + hashes = raw.group("hashes") + cursor = position + raw.end() + terminator = '"' + hashes + end = source.find(terminator, cursor) + return length if end < 0 else end + len(terminator) + + if source[position] == "'" and starts_lifetime(position): + return position + + if source[position] in ('"', "'"): + quote = source[position] + cursor = position + 1 + while cursor < length: + if source[cursor] == "\\": + cursor += 2 + continue + if source[cursor] == quote: + return cursor + 1 + cursor += 1 + return length + return position + + while index < length: + skipped = skip_non_code(index) + if skipped != index: + index = skipped + continue + match = re.match(r"impl\b", source[index:]) + if not match: + index += 1 + continue + if index > 0 and (source[index - 1].isalnum() or source[index - 1] == "_"): + index += 1 + continue + + start = index + cursor = index + match.end() + angle_depth = 0 + paren_depth = 0 + bracket_depth = 0 + nested_brace_depth = 0 + body_start: int | None = None + non_code_ranges: list[tuple[int, int]] = [] + + while cursor < length: + skipped = skip_non_code(cursor) + if skipped != cursor: + non_code_ranges.append((cursor, skipped)) + cursor = skipped + continue + char = source[cursor] + if nested_brace_depth: + if char == "{": + nested_brace_depth += 1 + elif char == "}": + nested_brace_depth -= 1 + cursor += 1 + continue + if char == "(": + paren_depth += 1 + elif char == ")" and paren_depth: + paren_depth -= 1 + elif char == "[": + bracket_depth += 1 + elif char == "]" and bracket_depth: + bracket_depth -= 1 + elif char == "<" and paren_depth == 0 and bracket_depth == 0: + angle_depth += 1 + elif char == ">" and angle_depth and paren_depth == 0 and bracket_depth == 0: + angle_depth -= 1 + elif char == "{": + if angle_depth == 0 and paren_depth == 0 and bracket_depth == 0: + body_start = cursor + break + nested_brace_depth = 1 + elif char == ";" and angle_depth == 0 and paren_depth == 0 and bracket_depth == 0: + break + cursor += 1 + + if body_start is not None: + header_parts: list[str] = [] + fragment_start = start + for non_code_start, non_code_end in non_code_ranges: + header_parts.append(source[fragment_start:non_code_start]) + header_parts.append(" ") + fragment_start = non_code_end + header_parts.append(source[fragment_start:body_start]) + headers.append(re.sub(r"\s+", " ", "".join(header_parts)).strip()) + index = body_start + 1 + else: + index = cursor + 1 + + return headers + + +class BrowserSessionRecoveryOperationContractTests(unittest.TestCase): + """Keep recovery I/O purpose-bounded to one exact fact on the consumed adapter.""" + + def test_recovery_dispatch_stays_inside_native_owner_module(self) -> None: + """Do not reopen raw adapter access to bridge recovery custody.""" + + lib_source = (CRATE / "src/lib.rs").read_text(encoding="utf-8") + browser_source = (CRATE / "src/browser_session.rs").read_text(encoding="utf-8") + recovery_source = (CRATE / "src/recovery.rs").read_text(encoding="utf-8") + + self.assertIn("mod browser_session;", lib_source) + self.assertNotIn('include!("browser_session.rs")', lib_source) + self.assertIn("pub(crate) fn dispatch_recovery_operation", browser_source) + self.assertNotIn("pub fn dispatch_recovery_operation", browser_source) + + for symbol in ( + "pub struct RecoveryContextOperationRequest", + "pub trait RecoveryContextOperationPort", + "pub enum RecoveryContextOperationError", + "pub fn execute_recovery_context_operation", + ): + self.assertIn(symbol, recovery_source) + + request_struct = recovery_source.split( + "pub struct RecoveryContextOperationRequest {", 1 + )[1].split("\n}", 1)[0] + self.assertNotRegex(request_struct, r"(?m)^\s*pub(?:\([^)]*\))?\s+") + self.assertIn("Option", request_struct) + self.assertIn("Option", request_struct) + self.assertNotIn("Vec", request_struct) + self.assertNotIn("Vec", request_struct) + + operation_impl = recovery_source.split( + "impl BoundBrowserSessionRecovery

", 1 + )[1].split("impl", 1)[0] + self.assertIn("fact: RecoveryFact", operation_impl) + self.assertIn("select_recovery_fact(fact)", operation_impl) + self.assertIn("RecoveryContextOperationError::AuthorityMismatch", operation_impl) + self.assertIn("RecoveryContextOperationError::StaleFact", operation_impl) + + request_impl_headers = [ + header + for header in _rust_impl_headers(recovery_source) + if "RecoveryContextOperationRequest" in header + ] + inherent_impl_headers = [ + header + for header in request_impl_headers + if " for RecoveryContextOperationRequest" not in header + ] + self.assertEqual( + inherent_impl_headers, + ["impl RecoveryContextOperationRequest"], + "request accessors must remain the sole inherent impl; every new inherent impl requires review", + ) + + request_impl = recovery_source.split( + "impl RecoveryContextOperationRequest", 1 + )[1].split("pub trait RecoveryContextOperationPort", 1)[0] + public_methods = re.findall( + r"(?m)^\s*pub(?:\s+const)?\s+fn\s+([A-Za-z0-9_]+)\s*\(([^)]*)\)", + request_impl, + ) + self.assertGreater(len(public_methods), 0) + for method_name, parameters in public_methods: + self.assertIn( + "&self", + parameters, + f"{method_name} must remain an accessor, not a public construction path", + ) + + for constructor_pattern in ( + r"impl(?:\s*<[^{}]*?>)?\s+(?:::)?(?:(?:core|std)::default::)?Default\s+for\s+RecoveryContextOperationRequest", + r"impl(?:\s*<[^{}]*?>)?\s+(?:::)?(?:(?:core|std)::convert::)?From<[^{}]+?>\s+for\s+RecoveryContextOperationRequest", + r"impl(?:\s*<[^{}]*?>)?\s+(?:::)?(?:(?:core|std)::convert::)?TryFrom<[^{}]+?>\s+for\s+RecoveryContextOperationRequest", + ): + self.assertNotRegex(recovery_source, constructor_pattern) + + self.assertNotIn("pub fn browser_session(&self)", recovery_source) + self.assertNotIn("pub fn port", recovery_source) + self.assertNotIn("pub const fn port", recovery_source) + + def test_impl_header_parser_keeps_braced_const_where_predicates_visible(self) -> None: + """A braced const expression in a where clause must not hide a second inherent impl.""" + + sample = """ +impl RecoveryContextOperationRequest { + pub fn operation(&self) -> &O { todo!() } +} +impl RecoveryContextOperationRequest +where + O: RecoveryMarker<{ 1 + 1 }>, +{ + pub fn from_raw(operation: O) -> Self { todo!() } +} +""" + request_headers = [ + header + for header in _rust_impl_headers(sample) + if "RecoveryContextOperationRequest" in header + ] + self.assertEqual(len(request_headers), 2) + self.assertIn("RecoveryMarker<{ 1 + 1 }>", request_headers[1]) + + def test_impl_header_parser_removes_non_code_text_before_classification(self) -> None: + """Comments and literals must not disguise an inherent request impl as a trait impl.""" + + sample = r''' +impl RecoveryContextOperationRequest { + pub fn operation(&self) -> &O { todo!() } +} +impl RecoveryContextOperationRequest +// for RecoveryContextOperationRequest +{ + pub fn from_raw(operation: O) -> Self { todo!() } +} +impl RecoveryContextOperationRequest +/* for RecoveryContextOperationRequest */ +{ + pub fn from_raw_again(operation: O) -> Self { todo!() } +} +impl RecoveryContextOperationRequest +where + O: RecoveryMarker<{ b" for RecoveryContextOperationRequest".len() }>, +{ + pub fn from_byte_literal(operation: O) -> Self { todo!() } +} +impl RecoveryContextOperationRequest +where + O: RecoveryMarker<{ br#" for RecoveryContextOperationRequest"#.len() }>, +{ + pub fn from_raw_literal(operation: O) -> Self { todo!() } +} +''' + request_headers = [ + header + for header in _rust_impl_headers(sample) + if "RecoveryContextOperationRequest" in header + ] + self.assertEqual(len(request_headers), 5) + self.assertEqual( + [ + header + for header in request_headers + if " for RecoveryContextOperationRequest" not in header + ], + request_headers, + ) + + def test_impl_header_parser_preserves_lifetimes_while_skipping_char_literals(self) -> None: + """Apostrophe handling must not consume lifetimes while removing character literals.""" + + sample = """ +impl<'a, O> RecoveryContextOperationRequest<&'a O> +where + O: RecoveryMarker<'a, {'x' as u32}>, +{ + pub fn borrow(&self) -> &'a O { todo!() } +} +""" + headers = _rust_impl_headers(sample) + self.assertEqual(len(headers), 1) + self.assertIn("impl<'a, O> RecoveryContextOperationRequest<&'a O>", headers[0]) + self.assertNotIn("'x'", headers[0]) + + def test_hostile_fixture_preserves_uncertainty_after_adapter_result(self) -> None: + """Adapter success or failure must not silently become reconciliation proof.""" + + hostile = (CRATE / "tests/recovery_same_adapter_operation.rs").read_text( + encoding="utf-8" + ) + for token in ( + "RecoveryContextOperationPort", + "execute_recovery_context_operation", + "recovery.recovery_fact(0)", + "recovery.create_attempt_recovery_fact(0)", + "request.browser_session()", + "request.incarnation()", + "request.state()", + "request.recovery_evidence()", + "request.create_attempt_recovery_evidence()", + "RecoveryContextOperationError::Adapter", + "expected_recovery_evidence", + "expected_create_attempt_recovery_evidence", + "request must expose only the selected exact recovery fact", + "request must expose only the selected exact create-attempt fact", + "generic recovery adapter success is not itself destruction or reconciliation proof", + "recovery operation dispatch must not erase unresolved ownership evidence", + "recovery operation dispatch must not erase create-attempt provenance", + ): + self.assertIn(token, hostile) + + def test_hostile_fixture_rejects_foreign_and_stale_operation_facts_before_io(self) -> None: + """One fact may authorize only its current exact recovery operation scope.""" + + hostile = (CRATE / "tests/recovery_operation_exact_fact_scope.rs").read_text( + encoding="utf-8" + ) + for token in ( + "execute_recovery_context_operation(first_fact", + "adapter request must expose only the selected recovery fact, not sibling uncertainty", + "RecoveryContextOperationError::StaleFact", + "RecoveryContextOperationError::AuthorityMismatch", + "a fact issued before ledger mutation must fail before adapter I/O", + "foreign_recovery_fact_is_rejected_before_operation_io", + ): + self.assertIn(token, hostile) + + def test_architecture_docs_describe_current_recovery_surface(self) -> None: + """ADR, traceability, and UML must not describe the pre-operation wrapper.""" + + adr = ( + ROOT / "docs/adr/0116-browser-session-recovery-custody-and-hot-ownership.md" + ).read_text(encoding="utf-8") + trace = ( + ROOT / "docs/traceability/browser-session-lifecycle-authority.md" + ).read_text(encoding="utf-8") + uml = (ROOT / "docs/uml/browser-session-lifecycle-authority.md").read_text( + encoding="utf-8" + ) + + for document in (adr, trace, uml): + self.assertIn("RecoveryContextOperationPort", document) + self.assertIn("RecoveryContextOperationRequest", document) + self.assertIn("same", document.lower()) + self.assertIn("RecoveryFact", document) + self.assertIn("pub(crate)", adr) + self.assertIn("pub(crate)", trace) + self.assertIn("success/failure does not clear Browser Session uncertainty", uml) + self.assertIn("#316", adr) + self.assertIn("#316", trace) + self.assertIn("#316", uml) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_recovery_settlement_contract.py b/tests/test_browser_session_recovery_settlement_contract.py new file mode 100644 index 000000000..5e4053550 --- /dev/null +++ b/tests/test_browser_session_recovery_settlement_contract.py @@ -0,0 +1,139 @@ +"""Repository contracts for proof-bearing Browser Session recovery settlement.""" + +from __future__ import annotations + +import pathlib +import re +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +CRATE = ROOT / "crates/originweave-browser-session" +RECOVERY = CRATE / "src/recovery.rs" +BROWSER_SESSION = CRATE / "src/browser_session.rs" +HOSTILE = CRATE / "tests/recovery_exact_fact_settlement.rs" +ADR = ROOT / "docs/adr/0116-browser-session-recovery-custody-and-hot-ownership.md" +TRACE = ROOT / "docs/traceability/browser-session-lifecycle-authority.md" +UML = ROOT / "docs/uml/browser-session-lifecycle-authority.md" +DOCTORING = ROOT / "docs/doctoring/browser-session-recovery-settlement.md" + + +def _struct_body(source: str, declaration: str) -> str: + """Return one simple Rust struct body used by the authority-surface contract.""" + + return source.split(declaration, 1)[1].split("\n}", 1)[0] + + +class BrowserSessionRecoverySettlementContractTests(unittest.TestCase): + """Keep recovery settlement exact-fact-bound and non-caller-constructible.""" + + def test_settlement_surface_is_explicit_and_opaque(self) -> None: + """New construction paths must not bypass Browser Session-issued fact custody.""" + + recovery_source = RECOVERY.read_text(encoding="utf-8") + browser_source = BROWSER_SESSION.read_text(encoding="utf-8") + + for symbol in ( + "pub struct RecoveryFact", + "pub struct RecoverySettlementRequest", + "pub trait RecoverySettlementPort", + "pub enum RecoverySettlementError", + "pub fn recovery_fact", + "pub fn create_attempt_recovery_fact", + "pub fn settle_recovery_fact", + ): + self.assertIn(symbol, recovery_source) + + for declaration in ( + "pub struct RecoveryFact {", + "pub struct RecoverySettlementRequest

{", + ): + body = _struct_body(recovery_source, declaration) + self.assertNotRegex( + body, + r"(?m)^\s*pub(?:\([^)]*\))?\s+", + f"{declaration} fields must stay private", + ) + + self.assertNotRegex( + recovery_source, + r"#\[derive\([^\]]*\bDefault\b[^\]]*\)\]\s*pub struct RecoveryFact", + ) + for constructor_pattern in ( + r"impl(?:\s*<[^{}]*?>)?\s+(?:::)?(?:(?:core|std)::default::)?Default\s+for\s+RecoveryFact", + r"impl(?:\s*<[^{}]*?>)?\s+(?:::)?(?:(?:core|std)::convert::)?From<[^{}]+?>\s+for\s+RecoveryFact", + r"impl(?:\s*<[^{}]*?>)?\s+(?:::)?(?:(?:core|std)::convert::)?TryFrom<[^{}]+?>\s+for\s+RecoveryFact", + ): + self.assertNotRegex(recovery_source, constructor_pattern) + + self.assertIn("pub(crate) fn dispatch_recovery_operation", browser_source) + self.assertNotIn("pub fn dispatch_recovery_operation", browser_source) + self.assertNotIn("pub fn port", recovery_source) + self.assertNotIn("pub const fn port", recovery_source) + + def test_settlement_order_pins_pre_io_validation_and_post_proof_commit(self) -> None: + """Fact validation must precede proof I/O, which must precede aggregate mutation.""" + + source = RECOVERY.read_text(encoding="utf-8") + selector = source.split("fn select_recovery_fact", 1)[1].split("\n }\n}", 1)[0] + method = source.split("pub fn settle_recovery_fact", 1)[1].split("\n }\n}", 1)[0] + + selector_authority = selector.index("RecoveryFactValidationError::AuthorityMismatch") + selector_revision = selector.index("fact.revision != self.revision") + selector_exact_fact = selector.index(".get(fact.index)") + selection = method.index(".select_recovery_fact(fact)") + verifier = method.index("verify_recovery_settlement") + retirement = method.index("settle_recovery_evidence_at") + revision_commit = method.index("self.revision = next_revision") + + self.assertLess(selector_authority, selector_revision) + self.assertLess(selector_revision, selector_exact_fact) + self.assertLess(selection, verifier) + self.assertLess(verifier, retirement) + self.assertLess(retirement, revision_commit) + self.assertIn("RecoveryFactLedger::CreateAttempt", selector) + self.assertIn("RecoveryFactLedger::CreateAttempt", method) + self.assertIn("settle_create_attempt_recovery_evidence_at", method) + + def test_hostile_fixture_pins_replay_sibling_foreign_and_dual_ledger_cases(self) -> None: + """The external fixture must keep realistic settlement abuse cases executable.""" + + hostile = HOSTILE.read_text(encoding="utf-8") + for token in ( + "RecoverySettlementPort", + "RecoverySettlementRequest", + "settle_recovery_fact", + "RecoverySettlementError::StaleFact", + "RecoverySettlementError::AuthorityMismatch", + "stale replay must fail before adapter proof verification", + "settling one fact invalidates previously issued sibling handles", + "foreign session/incarnation fact must fail before adapter proof verification", + "create_attempt_recovery_fact", + "BrowserSessionState::Ended", + "must never restore ordinary browser authority", + ): + self.assertIn(token, hostile) + + def test_architecture_docs_name_the_current_settlement_boundary(self) -> None: + """ADR/trace/UML/doctoring must describe proof-bearing settlement, not only dispatch.""" + + documents = [ + ADR.read_text(encoding="utf-8"), + TRACE.read_text(encoding="utf-8"), + UML.read_text(encoding="utf-8"), + DOCTORING.read_text(encoding="utf-8"), + ] + for document in documents: + for token in ( + "RecoveryFact", + "RecoverySettlementPort", + "RecoverySettlementRequest", + "settle_recovery_fact", + "#316", + ): + self.assertIn(token, document) + self.assertIn("revision", document.lower()) + self.assertIn("proof", document.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rust_compile_time_environment_authority_contract.py b/tests/test_browser_session_rust_compile_time_environment_authority_contract.py new file mode 100644 index 000000000..594cc7770 --- /dev/null +++ b/tests/test_browser_session_rust_compile_time_environment_authority_contract.py @@ -0,0 +1,283 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE_INDIRECTION_TEST = ROOT / "tests/test_browser_session_rust_source_indirection_contract.py" + +spec = importlib.util.spec_from_file_location( + "browser_session_rust_source_indirection_contract", + SOURCE_INDIRECTION_TEST, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Rust source-indirection contract") +source_indirection = importlib.util.module_from_spec(spec) +spec.loader.exec_module(source_indirection) + + +def _compile_time_environment_macro_token_end(text: str, offset: int) -> int | None: + """Return the end of a lexical env/option_env identifier token at offset.""" + for spelling in ("env", "option_env"): + end = source_indirection._rust_identifier_token_end( + text, + offset, + spelling, + allow_raw=True, + ) + if end is not None: + return end + return None + + +def _has_compile_time_environment_macro(text: str) -> bool: + """Detect compile-time environment reads outside Rust comments and literals.""" + cursor = 0 + while cursor < len(text): + trivia_end = source_indirection._skip_rust_trivia(text, cursor) + if trivia_end != cursor: + cursor = trivia_end + continue + + raw_end = source_indirection._raw_string_end(text, cursor) + if raw_end is not None: + cursor = raw_end + continue + if text[cursor] == '"': + cursor = source_indirection._quoted_string_end(text, cursor) + continue + if text[cursor] == "'": + char_end = source_indirection._simple_char_literal_end(text, cursor) + if char_end is not None: + cursor = char_end + continue + + token_end = _compile_time_environment_macro_token_end(text, cursor) + if token_end is not None: + bang = source_indirection._skip_rust_trivia(text, token_end) + if bang < len(text) and text[bang] == "!": + return True + cursor = token_end + continue + cursor += 1 + return False + + +def _use_tree_aliases_compile_time_environment_macro(use_tree: str) -> bool: + """Return whether one use tree gives env!/option_env! a callable alias.""" + cursor = 0 + while cursor < len(use_tree): + trivia_end = source_indirection._skip_rust_trivia(use_tree, cursor) + if trivia_end != cursor: + cursor = trivia_end + continue + + raw_end = source_indirection._raw_string_end(use_tree, cursor) + if raw_end is not None: + cursor = raw_end + continue + if use_tree[cursor] == '"': + cursor = source_indirection._quoted_string_end(use_tree, cursor) + continue + if use_tree[cursor] == "'": + char_end = source_indirection._simple_char_literal_end(use_tree, cursor) + if char_end is not None: + cursor = char_end + continue + + token_end = _compile_time_environment_macro_token_end(use_tree, cursor) + if token_end is None: + cursor += 1 + continue + + after_macro = source_indirection._skip_rust_trivia(use_tree, token_end) + as_end = source_indirection._rust_identifier_token_end(use_tree, after_macro, "as") + if as_end is None: + cursor = token_end + continue + + alias_start = source_indirection._skip_rust_trivia(use_tree, as_end) + if alias_start >= len(use_tree): + return False + if use_tree[alias_start] == "_": + next_offset = alias_start + 1 + if next_offset >= len(use_tree) or not source_indirection._rust_keyword_is_identifier_adjacent( + use_tree[next_offset] + ): + cursor = token_end + continue + return True + return False + + +def _has_aliased_compile_time_environment_import(text: str) -> bool: + """Detect lexical use aliases that hide compile-time environment macro names.""" + cursor = 0 + while cursor < len(text): + trivia_end = source_indirection._skip_rust_trivia(text, cursor) + if trivia_end != cursor: + cursor = trivia_end + continue + + raw_end = source_indirection._raw_string_end(text, cursor) + if raw_end is not None: + cursor = raw_end + continue + if text[cursor] == '"': + cursor = source_indirection._quoted_string_end(text, cursor) + continue + if text[cursor] == "'": + char_end = source_indirection._simple_char_literal_end(text, cursor) + if char_end is not None: + cursor = char_end + continue + + use_end = source_indirection._rust_identifier_token_end(text, cursor, "use") + if use_end is None: + cursor += 1 + continue + statement_end = source_indirection._rust_use_statement_end(text, use_end) + if statement_end is None: + return False + if _use_tree_aliases_compile_time_environment_macro(text[use_end:statement_end]): + return True + cursor = statement_end + 1 + return False + + +def _assert_no_unmodeled_rust_compile_time_environment_inputs(root: pathlib.Path) -> None: + """Fail closed when reviewed Rust source reads ambient build environment values.""" + source_indirection._assert_no_unmodeled_rust_source_indirection(root) + for source in source_indirection.boundary._workspace_production_sources(root): + text = source.read_text(encoding="utf-8") + if _has_compile_time_environment_macro(text) or _has_aliased_compile_time_environment_import(text): + relative = source.relative_to(root).as_posix() + raise AssertionError( + "Rust compile-time environment input requires an explicit provenance contract: " + f"{relative}" + ) + + +class BrowserSessionRustCompileTimeEnvironmentAuthorityContractTests(unittest.TestCase): + """Keep Rust compile-time environment inputs inside reviewed provenance.""" + + def _workspace_with_source(self, source_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (adapter / "src/lib.rs").write_text(source_text, encoding="utf-8") + return root + + def test_current_production_sources_have_no_unmodeled_compile_time_environment_inputs(self) -> None: + _assert_no_unmodeled_rust_compile_time_environment_inputs(ROOT) + + def test_env_macro_fails_closed(self) -> None: + root = self._workspace_with_source( + 'pub const BUILD_ID: &str = env!("ORIGINWEAVE_UNREVIEWED_BUILD_ID");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust compile-time environment input"): + _assert_no_unmodeled_rust_compile_time_environment_inputs(root) + + def test_option_env_macro_fails_closed(self) -> None: + root = self._workspace_with_source( + 'pub const BUILD_ID: Option<&str> = option_env!("ORIGINWEAVE_UNREVIEWED_BUILD_ID");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust compile-time environment input"): + _assert_no_unmodeled_rust_compile_time_environment_inputs(root) + + def test_namespaced_env_macro_fails_closed(self) -> None: + root = self._workspace_with_source( + 'pub const BUILD_ID: &str = std::env!("ORIGINWEAVE_UNREVIEWED_BUILD_ID");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust compile-time environment input"): + _assert_no_unmodeled_rust_compile_time_environment_inputs(root) + + def test_namespaced_option_env_macro_fails_closed(self) -> None: + root = self._workspace_with_source( + 'pub const BUILD_ID: Option<&str> = core::option_env!("ORIGINWEAVE_UNREVIEWED_BUILD_ID");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust compile-time environment input"): + _assert_no_unmodeled_rust_compile_time_environment_inputs(root) + + def test_aliased_env_macro_fails_closed(self) -> None: + root = self._workspace_with_source( + 'use std::env as read_build_env;\n' + 'pub const BUILD_ID: &str = read_build_env!("ORIGINWEAVE_UNREVIEWED_BUILD_ID");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust compile-time environment input"): + _assert_no_unmodeled_rust_compile_time_environment_inputs(root) + + def test_grouped_aliased_option_env_macro_fails_closed(self) -> None: + root = self._workspace_with_source( + 'use core::{option_env as read_optional_build_env};\n' + 'pub const BUILD_ID: Option<&str> = read_optional_build_env!("ORIGINWEAVE_UNREVIEWED_BUILD_ID");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust compile-time environment input"): + _assert_no_unmodeled_rust_compile_time_environment_inputs(root) + + def test_raw_identifier_aliased_env_macro_fails_closed(self) -> None: + root = self._workspace_with_source( + 'use std::env as r#type;\n' + 'pub const BUILD_ID: &str = r#type!("ORIGINWEAVE_UNREVIEWED_BUILD_ID");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust compile-time environment input"): + _assert_no_unmodeled_rust_compile_time_environment_inputs(root) + + def test_underscore_import_is_not_callable_compile_time_environment_authority(self) -> None: + root = self._workspace_with_source( + 'use std::env as _;\n' + 'pub fn reviewed_runtime_environment() -> Option { std::env::var("PATH").ok() }\n' + ) + + _assert_no_unmodeled_rust_compile_time_environment_inputs(root) + + def test_unicode_continuation_after_underscore_is_not_discard_alias(self) -> None: + root = self._workspace_with_source( + 'use std::env as _\u0301;\n' + 'pub const BUILD_ID: &str = _\u0301!("ORIGINWEAVE_UNREVIEWED_BUILD_ID");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust compile-time environment input"): + _assert_no_unmodeled_rust_compile_time_environment_inputs(root) + + def test_comment_string_and_raw_string_mentions_are_not_compile_time_environment_authority(self) -> None: + root = self._workspace_with_source( + '// env!("ORIGINWEAVE_UNREVIEWED_BUILD_ID")\n' + '// use std::env as hidden_build_env;\n' + 'pub const NOTE: &str = "option_env!(\\\"ORIGINWEAVE_UNREVIEWED_BUILD_ID\\\")";\n' + 'pub const RAW_NOTE: &str = r#"use std::env as hidden_raw_build_env; env!("ORIGINWEAVE_UNREVIEWED_BUILD_ID")"#;\n' + 'pub fn env_count() -> usize { 0 }\n' + ) + + _assert_no_unmodeled_rust_compile_time_environment_inputs(root) + + def test_character_literal_does_not_hide_following_real_macro(self) -> None: + root = self._workspace_with_source( + "pub const MARKER: char = 'x';\n" + 'pub const BUILD_ID: &str = env!("ORIGINWEAVE_UNREVIEWED_BUILD_ID");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust compile-time environment input"): + _assert_no_unmodeled_rust_compile_time_environment_inputs(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rust_compile_time_environment_identifier_boundary_contract.py b/tests/test_browser_session_rust_compile_time_environment_identifier_boundary_contract.py new file mode 100644 index 000000000..b22c5589b --- /dev/null +++ b/tests/test_browser_session_rust_compile_time_environment_identifier_boundary_contract.py @@ -0,0 +1,80 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +COMPILE_TIME_ENVIRONMENT_TEST = ( + ROOT / "tests/test_browser_session_rust_compile_time_environment_authority_contract.py" +) + +spec = importlib.util.spec_from_file_location( + "browser_session_rust_compile_time_environment_authority_contract", + COMPILE_TIME_ENVIRONMENT_TEST, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session compile-time environment contract") +compile_time_environment = importlib.util.module_from_spec(spec) +spec.loader.exec_module(compile_time_environment) + + +class BrowserSessionRustCompileTimeEnvironmentIdentifierBoundaryContractTests(unittest.TestCase): + """Keep env!/option_env! detection aligned with Rust identifier boundaries.""" + + def _workspace_with_source(self, source_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (adapter / "src/lib.rs").write_text(source_text, encoding="utf-8") + return root + + def test_unicode_identifier_continuation_before_env_is_not_builtin_macro(self) -> None: + source = ( + "macro_rules! _\u0301env { () => { \"reviewed\" }; }\n" + "pub const BUILD_ID: &str = _\u0301env!();\n" + ) + + self.assertFalse( + compile_time_environment._has_compile_time_environment_macro(source), + "a Rust XID_Continue character before env must keep env inside the user macro identifier", + ) + + def test_real_env_macro_remains_detected(self) -> None: + self.assertTrue( + compile_time_environment._has_compile_time_environment_macro( + 'pub const BUILD_ID: &str = env!("ORIGINWEAVE_UNREVIEWED_BUILD_ID");\n' + ) + ) + + def test_unicode_identifier_continuation_before_use_is_macro_name_data(self) -> None: + root = self._workspace_with_source( + "macro_rules! a\u0301use { ($($token:tt)*) => {}; }\n" + "a\u0301use!(std::env as hidden_build_env);\n" + 'pub fn reviewed_runtime_environment() -> Option { std::env::var("PATH").ok() }\n' + ) + + compile_time_environment._assert_no_unmodeled_rust_compile_time_environment_inputs(root) + + def test_real_use_alias_remains_compile_time_environment_authority(self) -> None: + root = self._workspace_with_source( + "use std::env as hidden_build_env;\n" + 'pub const BUILD_ID: &str = hidden_build_env!("ORIGINWEAVE_UNREVIEWED_BUILD_ID");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust compile-time environment input"): + compile_time_environment._assert_no_unmodeled_rust_compile_time_environment_inputs(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rust_embedded_file_input_authority_contract.py b/tests/test_browser_session_rust_embedded_file_input_authority_contract.py new file mode 100644 index 000000000..3b087dcb4 --- /dev/null +++ b/tests/test_browser_session_rust_embedded_file_input_authority_contract.py @@ -0,0 +1,283 @@ +import importlib.util +import pathlib +import re +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE_INDIRECTION_TEST = ROOT / "tests/test_browser_session_rust_source_indirection_contract.py" +EMBEDDED_FILE_MACRO_TOKEN = re.compile( + r"(? bool: + """Detect compile-time file embedding outside Rust comments and literals.""" + cursor = 0 + while cursor < len(text): + trivia_end = source_indirection._skip_rust_trivia(text, cursor) + if trivia_end != cursor: + cursor = trivia_end + continue + + raw_end = source_indirection._raw_string_end(text, cursor) + if raw_end is not None: + cursor = raw_end + continue + if text[cursor] == '"': + cursor = source_indirection._quoted_string_end(text, cursor) + continue + if text[cursor] == "'": + char_end = source_indirection._simple_char_literal_end(text, cursor) + if char_end is not None: + cursor = char_end + continue + + match = EMBEDDED_FILE_MACRO_TOKEN.match(text, cursor) + if match is not None: + bang = source_indirection._skip_rust_trivia(text, match.end()) + if bang < len(text) and text[bang] == "!": + return True + cursor = match.end() + continue + cursor += 1 + return False + + +def _use_tree_aliases_embedded_file_macro(use_tree: str) -> bool: + """Return whether one Rust use tree gives include_bytes!/include_str! a callable alias.""" + cursor = 0 + while cursor < len(use_tree): + trivia_end = source_indirection._skip_rust_trivia(use_tree, cursor) + if trivia_end != cursor: + cursor = trivia_end + continue + + match = EMBEDDED_FILE_MACRO_TOKEN.match(use_tree, cursor) + if match is None: + cursor += 1 + continue + + after_macro = source_indirection._skip_rust_trivia(use_tree, match.end()) + as_match = source_indirection.AS_TOKEN.match(use_tree, after_macro) + if as_match is not None: + alias_start = source_indirection._skip_rust_trivia(use_tree, as_match.end()) + if alias_start >= len(use_tree): + return False + if use_tree[alias_start] == "_": + next_offset = alias_start + 1 + if next_offset >= len(use_tree) or not source_indirection._rust_keyword_is_identifier_adjacent( + use_tree[next_offset] + ): + cursor = match.end() + continue + return True + cursor = match.end() + return False + + +def _has_aliased_embedded_file_import(text: str) -> bool: + """Detect lexical use aliases that would hide embedded-file macro names at invocation.""" + cursor = 0 + while cursor < len(text): + trivia_end = source_indirection._skip_rust_trivia(text, cursor) + if trivia_end != cursor: + cursor = trivia_end + continue + + raw_end = source_indirection._raw_string_end(text, cursor) + if raw_end is not None: + cursor = raw_end + continue + if text[cursor] == '"': + cursor = source_indirection._quoted_string_end(text, cursor) + continue + if text[cursor] == "'": + char_end = source_indirection._simple_char_literal_end(text, cursor) + if char_end is not None: + cursor = char_end + continue + + use_match = source_indirection.USE_TOKEN.match(text, cursor) + if use_match is None: + cursor += 1 + continue + + statement_end = source_indirection._rust_use_statement_end(text, use_match.end()) + if statement_end is None: + return False + if _use_tree_aliases_embedded_file_macro(text[use_match.end():statement_end]): + return True + cursor = statement_end + 1 + return False + + +def _assert_no_unmodeled_rust_embedded_file_inputs(root: pathlib.Path) -> None: + """Fail closed when reviewed Rust source embeds file bytes outside the source closure.""" + source_indirection._assert_no_unmodeled_rust_source_indirection(root) + for source in source_indirection.boundary._workspace_production_sources(root): + text = source.read_text(encoding="utf-8") + if _has_embedded_file_macro(text) or _has_aliased_embedded_file_import(text): + relative = source.relative_to(root).as_posix() + raise AssertionError( + "Rust embedded file input requires an explicit provenance contract: " + f"{relative}" + ) + + +class BrowserSessionRustEmbeddedFileInputAuthorityContractTests(unittest.TestCase): + """Keep compile-time embedded files inside explicit Browser Session source provenance.""" + + def _workspace_with_source(self, source_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (adapter / "src/lib.rs").write_text(source_text, encoding="utf-8") + (adapter / "unreviewed.bin").write_bytes(b"unreviewed-browser-runtime-bytes") + (adapter / "unreviewed.txt").write_text( + "unreviewed browser runtime text\n", + encoding="utf-8", + ) + return root + + def test_current_production_sources_have_no_unmodeled_embedded_file_inputs(self) -> None: + _assert_no_unmodeled_rust_embedded_file_inputs(ROOT) + + def test_include_bytes_file_input_fails_closed(self) -> None: + root = self._workspace_with_source( + 'pub static EMBEDDED: &[u8] = include_bytes!("../unreviewed.bin");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust embedded file input"): + _assert_no_unmodeled_rust_embedded_file_inputs(root) + + def test_include_str_file_input_fails_closed(self) -> None: + root = self._workspace_with_source( + 'pub static EMBEDDED: &str = include_str!("../unreviewed.txt");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust embedded file input"): + _assert_no_unmodeled_rust_embedded_file_inputs(root) + + def test_namespaced_include_bytes_file_input_fails_closed(self) -> None: + root = self._workspace_with_source( + 'pub static EMBEDDED: &[u8] = core::include_bytes!("../unreviewed.bin");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust embedded file input"): + _assert_no_unmodeled_rust_embedded_file_inputs(root) + + def test_aliased_include_bytes_file_input_fails_closed(self) -> None: + root = self._workspace_with_source( + 'use core::include_bytes as read_blob;\n' + 'pub static EMBEDDED: &[u8] = read_blob!("../unreviewed.bin");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust embedded file input"): + _assert_no_unmodeled_rust_embedded_file_inputs(root) + + def test_grouped_aliased_include_bytes_file_input_fails_closed(self) -> None: + root = self._workspace_with_source( + 'use core::{include_bytes as read_blob};\n' + 'pub static EMBEDDED: &[u8] = read_blob!("../unreviewed.bin");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust embedded file input"): + _assert_no_unmodeled_rust_embedded_file_inputs(root) + + def test_underscore_prefixed_alias_still_fails_closed(self) -> None: + root = self._workspace_with_source( + 'use core::include_bytes as _read_blob;\n' + 'pub static EMBEDDED: &[u8] = _read_blob!("../unreviewed.bin");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust embedded file input"): + _assert_no_unmodeled_rust_embedded_file_inputs(root) + + def test_unicode_continuation_include_bytes_alias_fails_closed(self) -> None: + root = self._workspace_with_source( + 'use core::include_bytes as _\u0301;\n' + 'pub static EMBEDDED: &[u8] = _\u0301!("../unreviewed.bin");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust embedded file input"): + _assert_no_unmodeled_rust_embedded_file_inputs(root) + + def test_aliased_include_str_file_input_fails_closed(self) -> None: + root = self._workspace_with_source( + 'use std::include_str as read_text;\n' + 'pub static EMBEDDED: &str = read_text!("../unreviewed.txt");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust embedded file input"): + _assert_no_unmodeled_rust_embedded_file_inputs(root) + + def test_unicode_continuation_include_str_alias_fails_closed(self) -> None: + root = self._workspace_with_source( + 'use std::include_str as _\u0301;\n' + 'pub static EMBEDDED: &str = _\u0301!("../unreviewed.txt");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust embedded file input"): + _assert_no_unmodeled_rust_embedded_file_inputs(root) + + def test_underscore_import_is_not_callable_alias_authority(self) -> None: + root = self._workspace_with_source( + 'use core::include_bytes as _;\n' + 'pub fn embedded_file_authority_control() -> usize { 0 }\n' + ) + + _assert_no_unmodeled_rust_embedded_file_inputs(root) + + def test_raw_string_alias_text_is_not_embedded_file_authority(self) -> None: + root = self._workspace_with_source( + 'pub const NOTE: &str = r#"use core::include_bytes as read_blob; ' + 'read_blob!(\\"../unreviewed.bin\\")"#;\n' + ) + + _assert_no_unmodeled_rust_embedded_file_inputs(root) + + def test_character_literal_does_not_hide_following_aliased_file_input(self) -> None: + root = self._workspace_with_source( + "pub const MARKER: char = 'x';\n" + 'use core::include_bytes as read_blob;\n' + 'pub static EMBEDDED: &[u8] = read_blob!("../unreviewed.bin");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust embedded file input"): + _assert_no_unmodeled_rust_embedded_file_inputs(root) + + def test_comment_and_string_mentions_are_not_embedded_file_authority(self) -> None: + root = self._workspace_with_source( + '// include_bytes!("../unreviewed.bin")\n' + '// use core::include_bytes as hidden_in_comment;\n' + 'pub const NOTE: &str = "include_str!(\\\"../unreviewed.txt\\\")";\n' + 'pub fn include_bytes_count() -> usize { 0 }\n' + ) + + _assert_no_unmodeled_rust_embedded_file_inputs(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rust_include_alias_contract.py b/tests/test_browser_session_rust_include_alias_contract.py new file mode 100644 index 000000000..07e915487 --- /dev/null +++ b/tests/test_browser_session_rust_include_alias_contract.py @@ -0,0 +1,61 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE_INDIRECTION_TEST = ROOT / "tests/test_browser_session_rust_source_indirection_contract.py" + +spec = importlib.util.spec_from_file_location( + "browser_session_rust_source_indirection", + SOURCE_INDIRECTION_TEST, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Rust source-indirection contract") +source_indirection = importlib.util.module_from_spec(spec) +spec.loader.exec_module(source_indirection) + + +class BrowserSessionRustIncludeAliasContractTests(unittest.TestCase): + """Prove that renaming Rust's include macro cannot bypass source provenance review.""" + + def _assert_alias_fails_closed(self, source_text: str) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (adapter / "src/lib.rs").write_text(source_text, encoding="utf-8") + (adapter / "generated_adapter.rs").write_text( + "pub fn generated_adapter_surface() {}\n", + encoding="utf-8", + ) + + with self.assertRaisesRegex( + AssertionError, + "Rust include! source indirection", + ): + source_indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_aliased_include_macro_fails_closed(self) -> None: + self._assert_alias_fails_closed( + 'use core::include as embed;\nembed!("../generated_adapter.rs");\n' + ) + + def test_grouped_raw_include_alias_with_comment_trivia_fails_closed(self) -> None: + self._assert_alias_fails_closed( + 'use core::{r#include /* provenance trivia */ as embed};\n' + 'embed!["../generated_adapter.rs"];\n' + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rust_include_lexical_control_contract.py b/tests/test_browser_session_rust_include_lexical_control_contract.py new file mode 100644 index 000000000..129a966e8 --- /dev/null +++ b/tests/test_browser_session_rust_include_lexical_control_contract.py @@ -0,0 +1,102 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE_INDIRECTION_TEST = ROOT / "tests/test_browser_session_rust_source_indirection_contract.py" + +spec = importlib.util.spec_from_file_location( + "browser_session_rust_source_indirection_contract", + SOURCE_INDIRECTION_TEST, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Rust source-indirection contract") +source_indirection = importlib.util.module_from_spec(spec) +spec.loader.exec_module(source_indirection) + + +class BrowserSessionRustIncludeLexicalControlContractTests(unittest.TestCase): + """Keep Rust include! authority lexical rather than matching comment or literal text.""" + + def _workspace_with_source(self, source_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (adapter / "src/lib.rs").write_text(source_text, encoding="utf-8") + (adapter / "generated_adapter.rs").write_text( + "pub fn generated_adapter_surface() {}\n", + encoding="utf-8", + ) + return root + + def test_commented_include_macro_text_is_not_source_indirection(self) -> None: + root = self._workspace_with_source( + '// include!("../generated_adapter.rs");\n' + 'pub fn reviewed_surface() {}\n' + ) + + source_indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_string_include_macro_text_is_not_source_indirection(self) -> None: + root = self._workspace_with_source( + 'pub const NOTE: &str = "include!(\\\"../generated_adapter.rs\\\")";\n' + ) + + source_indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_raw_string_include_macro_text_is_not_source_indirection(self) -> None: + root = self._workspace_with_source( + 'pub const NOTE: &str = r#"include!(\\"../generated_adapter.rs\\")"#;\n' + ) + + source_indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_commented_aliased_include_import_is_not_source_indirection(self) -> None: + root = self._workspace_with_source( + '// use core::include as hidden_include;\n' + 'pub fn reviewed_surface() {}\n' + ) + + source_indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_grouped_use_comment_alias_text_is_not_source_indirection(self) -> None: + root = self._workspace_with_source( + 'use core::{\n' + ' /* include as hidden_include */\n' + ' fmt,\n' + '};\n' + 'pub fn reviewed_surface() { let _ = fmt::Error; }\n' + ) + + source_indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_character_literal_does_not_hide_following_real_include(self) -> None: + root = self._workspace_with_source( + "pub const MARKER: char = 'x';\n" + 'include!("../generated_adapter.rs");\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust include! source indirection"): + source_indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_real_include_macro_still_fails_closed(self) -> None: + root = self._workspace_with_source('include!("../generated_adapter.rs");\n') + + with self.assertRaisesRegex(AssertionError, "Rust include! source indirection"): + source_indirection._assert_no_unmodeled_rust_source_indirection(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rust_native_link_attribute_authority_contract.py b/tests/test_browser_session_rust_native_link_attribute_authority_contract.py new file mode 100644 index 000000000..5e2e5cbc7 --- /dev/null +++ b/tests/test_browser_session_rust_native_link_attribute_authority_contract.py @@ -0,0 +1,151 @@ +import importlib.util +import pathlib +import re +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +BOUNDARY_TEST = ROOT / "tests/test_browser_session_trusted_adapter_boundary.py" +SOURCE_INDIRECTION_TEST = ROOT / "tests/test_browser_session_rust_source_indirection_contract.py" +LINK_META_TOKEN = re.compile(r"(? bool: + """Detect link(...) meta while ignoring Rust comments and string/character literals.""" + cursor = 0 + while cursor < len(attribute_body): + trivia_end = source_indirection._skip_rust_trivia(attribute_body, cursor) + if trivia_end != cursor: + cursor = trivia_end + continue + + raw_end = source_indirection._raw_string_end(attribute_body, cursor) + if raw_end is not None: + cursor = raw_end + continue + if attribute_body[cursor] == '"': + cursor = source_indirection._quoted_string_end(attribute_body, cursor) + continue + if attribute_body[cursor] == "'": + char_end = source_indirection._simple_char_literal_end(attribute_body, cursor) + if char_end is not None: + cursor = char_end + continue + + match = LINK_META_TOKEN.match(attribute_body, cursor) + if match is not None: + operand = source_indirection._skip_rust_trivia(attribute_body, match.end()) + if operand < len(attribute_body) and attribute_body[operand] == "(": + return True + cursor = match.end() + continue + cursor += 1 + return False + + +def _assert_no_unmodeled_rust_native_link_inputs(root: pathlib.Path) -> None: + """Fail closed when reviewed Rust source selects unresolved native-library bytes.""" + source_indirection._assert_no_unmodeled_rust_source_indirection(root) + for source in boundary._workspace_production_sources(root): + text = source.read_text(encoding="utf-8") + for attribute_body in source_indirection._rust_attribute_bodies(text): + if _attribute_contains_native_link_meta(attribute_body): + relative = source.relative_to(root).as_posix() + raise AssertionError( + "Rust link attribute requires an explicit native-library provenance contract: " + f"{relative}" + ) + + +class BrowserSessionRustNativeLinkAttributeAuthorityContractTests(unittest.TestCase): + """Keep source-selected native libraries inside reviewed Browser Session provenance.""" + + def _workspace_with_source(self, source_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (adapter / "src/lib.rs").write_text(source_text, encoding="utf-8") + return root + + def test_current_production_sources_have_no_unmodeled_native_link_attributes(self) -> None: + _assert_no_unmodeled_rust_native_link_inputs(ROOT) + + def test_direct_native_link_attribute_fails_closed(self) -> None: + root = self._workspace_with_source( + '#[link(name = "review_bypass", kind = "static")]\n' + 'unsafe extern "C" { fn reviewed_symbol(); }\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust link attribute"): + _assert_no_unmodeled_rust_native_link_inputs(root) + + def test_cfg_attr_native_link_attribute_fails_closed(self) -> None: + root = self._workspace_with_source( + '#[cfg_attr(unix, link(name = "review_bypass"))]\n' + 'unsafe extern "C" { fn reviewed_symbol(); }\n' + ) + + with self.assertRaisesRegex(AssertionError, "Rust link attribute"): + _assert_no_unmodeled_rust_native_link_inputs(root) + + def test_commented_native_link_attribute_is_not_native_link_authority(self) -> None: + root = self._workspace_with_source( + '// #[link(name = "review_bypass")]\n' + 'pub fn documented() {}\n' + ) + + _assert_no_unmodeled_rust_native_link_inputs(root) + + def test_string_containing_native_link_attribute_is_not_native_link_authority(self) -> None: + root = self._workspace_with_source( + 'pub const NOTE: &str = "#[link(name = \\"review_bypass\\")]";\n' + ) + + _assert_no_unmodeled_rust_native_link_inputs(root) + + def test_link_word_inside_attribute_string_is_not_native_link_authority(self) -> None: + root = self._workspace_with_source( + '#[doc = "link(name = \\\"not_an_attribute\\\")"]\n' + 'pub fn documented() {}\n' + ) + + _assert_no_unmodeled_rust_native_link_inputs(root) + + def test_link_section_attribute_is_not_native_library_selection(self) -> None: + root = self._workspace_with_source( + '#[unsafe(link_section = ".reviewed_section")]\n' + 'pub static REVIEWED: u8 = 1;\n' + ) + + _assert_no_unmodeled_rust_native_link_inputs(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rust_path_comment_trivia_contract.py b/tests/test_browser_session_rust_path_comment_trivia_contract.py new file mode 100644 index 000000000..3a5c54f4c --- /dev/null +++ b/tests/test_browser_session_rust_path_comment_trivia_contract.py @@ -0,0 +1,71 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE_INDIRECTION_TEST = ROOT / "tests/test_browser_session_rust_source_indirection_contract.py" + +spec = importlib.util.spec_from_file_location( + "browser_session_rust_source_indirection_contract", + SOURCE_INDIRECTION_TEST, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Rust source-indirection contract") +source_indirection = importlib.util.module_from_spec(spec) +spec.loader.exec_module(source_indirection) + + +class BrowserSessionRustPathCommentTriviaContractTests(unittest.TestCase): + """Prove Rust comment trivia cannot hide a path-bearing source attribute.""" + + def _workspace_with_source(self, source_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (adapter / "src/lib.rs").write_text(source_text, encoding="utf-8") + (adapter / "src/nested.rs").write_text( + "pub fn nested_adapter_surface() {}\n", + encoding="utf-8", + ) + return root + + def _assert_path_attribute_fails_closed(self, source_text: str) -> None: + root = self._workspace_with_source(source_text) + with self.assertRaisesRegex(AssertionError, "Rust path attribute requires"): + source_indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_block_comment_between_path_and_equals_fails_closed(self) -> None: + self._assert_path_attribute_fails_closed( + '#[path /* reviewed trivia */ = "nested.rs"]\nmod nested;\n' + ) + + def test_nested_block_comment_between_path_and_equals_fails_closed(self) -> None: + self._assert_path_attribute_fails_closed( + '#[path /* outer /* nested */ trivia */ = "nested.rs"]\nmod nested;\n' + ) + + def test_line_comment_between_path_and_equals_fails_closed(self) -> None: + self._assert_path_attribute_fails_closed( + '#[path // reviewed trivia\n = "nested.rs"]\nmod nested;\n' + ) + + def test_closing_bracket_inside_comment_cannot_truncate_attribute_scan(self) -> None: + self._assert_path_attribute_fails_closed( + '#[path /* ] reviewed trivia */ = "nested.rs"]\nmod nested;\n' + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rust_response_file_contract.py b/tests/test_browser_session_rust_response_file_contract.py new file mode 100644 index 000000000..ad185ce84 --- /dev/null +++ b/tests/test_browser_session_rust_response_file_contract.py @@ -0,0 +1,60 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionRustResponseFileContractTests(unittest.TestCase): + """Keep rustc/rustdoc response-file inputs inside reviewed Cargo provenance.""" + + def test_top_level_rust_response_files_fail_closed(self) -> None: + hostile_configs = ( + '[build]\nrustflags = ["@tools/review-bypass-rustc.args"]\n', + "[target.'cfg(unix)']\nrustflags = [\"@tools/review-bypass-rustc.args\"]\n", + '[build]\nrustdocflags = ["@tools/review-bypass-rustdoc.args"]\n', + "[target.'cfg(unix)']\nrustdocflags = [\"@tools/review-bypass-rustdoc.args\"]\n", + ) + for config_text in hostile_configs: + with self.subTest(config_text=config_text): + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_at_sign_inside_non_response_argument_remains_allowed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustflags = ["--cfg", "originweave_contact=\\\"ops@example.invalid\\\""]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = self.enterContext(tempfile.TemporaryDirectory()) + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rust_source_indirection_contract.py b/tests/test_browser_session_rust_source_indirection_contract.py new file mode 100644 index 000000000..6119ba9dc --- /dev/null +++ b/tests/test_browser_session_rust_source_indirection_contract.py @@ -0,0 +1,660 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +BOUNDARY_TEST = ROOT / "tests/test_browser_session_trusted_adapter_boundary.py" + +spec = importlib.util.spec_from_file_location("browser_session_trusted_adapter_boundary", BOUNDARY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session trusted-adapter boundary contract") +boundary = importlib.util.module_from_spec(spec) +spec.loader.exec_module(boundary) + + +INCLUDE_TOKEN = "include" +CUSTOM_TARGET_MOD_TOKEN = "mod" +RUST_PATTERN_WHITESPACE = frozenset( + "\u0009\u000a\u000b\u000c\u000d\u0020\u0085\u200e\u200f\u2028\u2029" +) +APPROVED_RUST_PATH_ATTRIBUTES = { + ("crates/originweave-core/src/root.rs", 'path = "lib.rs"'), +} + + +def _normalized_attribute_body(body: str) -> str: + return " ".join(body.split()) + + +def _skip_rust_trivia(text: str, offset: int) -> int: + """Skip Rust whitespace and nested non-doc comments without changing token meaning.""" + index = offset + while index < len(text): + if text[index] in RUST_PATTERN_WHITESPACE: + index += 1 + continue + if text.startswith("//", index): + newline = text.find("\n", index + 2) + index = len(text) if newline < 0 else newline + 1 + continue + if text.startswith("/*", index): + depth = 1 + index += 2 + while index < len(text) and depth: + if text.startswith("/*", index): + depth += 1 + index += 2 + elif text.startswith("*/", index): + depth -= 1 + index += 2 + else: + index += 1 + if depth: + raise AssertionError("unterminated Rust block comment in source attribute") + continue + break + return index + + +def _rust_keyword_is_identifier_adjacent(char: str) -> bool: + """Conservatively reject token boundaries that could be Rust identifier continuation.""" + if char.isascii(): + return char.isalnum() or char == "_" + return char not in RUST_PATTERN_WHITESPACE + + +def _rust_identifier_token_end( + text: str, + offset: int, + spelling: str, + *, + allow_raw: bool = False, +) -> int | None: + """Return a conservative Rust identifier-token end independent of Python Unicode tables.""" + token_start = offset + identifier_start = offset + if allow_raw and text.startswith("r#", offset) and text.startswith(spelling, offset + 2): + identifier_start = offset + 2 + elif not text.startswith(spelling, offset): + return None + + if token_start: + previous = text[token_start - 1] + if previous == "#" or _rust_keyword_is_identifier_adjacent(previous): + return None + + end = identifier_start + len(spelling) + if end < len(text) and _rust_keyword_is_identifier_adjacent(text[end]): + return None + return end + + +def _has_include_macro(text: str) -> bool: + """Detect lexical include! macro syntax outside Rust comments and literals.""" + cursor = 0 + while cursor < len(text): + trivia_end = _skip_rust_trivia(text, cursor) + if trivia_end != cursor: + cursor = trivia_end + continue + + raw_end = _raw_string_end(text, cursor) + if raw_end is not None: + cursor = raw_end + continue + if text[cursor] == '"': + cursor = _quoted_string_end(text, cursor) + continue + if text[cursor] == "'": + char_end = _simple_char_literal_end(text, cursor) + if char_end is not None: + cursor = char_end + continue + + token_end = _rust_identifier_token_end(text, cursor, INCLUDE_TOKEN, allow_raw=True) + if token_end is None: + cursor += 1 + continue + bang = _skip_rust_trivia(text, token_end) + if bang < len(text) and text[bang] == "!": + delimiter = _skip_rust_trivia(text, bang + 1) + if delimiter < len(text) and text[delimiter] in "([{": + return True + cursor = token_end + return False + + +def _matches_custom_target_mod_token(text: str, offset: int) -> bool: + """Match the Rust `mod` keyword without relying on Python's Unicode identifier table.""" + return _rust_identifier_token_end(text, offset, CUSTOM_TARGET_MOD_TOKEN) is not None + + +def _has_custom_target_mod_token(text: str) -> bool: + """Detect lexical Rust mod tokens outside comments and string/character literals.""" + cursor = 0 + while cursor < len(text): + trivia_end = _skip_rust_trivia(text, cursor) + if trivia_end != cursor: + cursor = trivia_end + continue + + raw_end = _raw_string_end(text, cursor) + if raw_end is not None: + cursor = raw_end + continue + if text[cursor] == '"': + cursor = _quoted_string_end(text, cursor) + continue + if text[cursor] == "'": + char_end = _simple_char_literal_end(text, cursor) + if char_end is not None: + cursor = char_end + continue + + if _matches_custom_target_mod_token(text, cursor): + return True + cursor += 1 + return False + + +def _rust_use_statement_end(text: str, offset: int) -> int | None: + """Return the semicolon ending one Rust use declaration while ignoring comment trivia.""" + cursor = offset + while cursor < len(text): + trivia_end = _skip_rust_trivia(text, cursor) + if trivia_end != cursor: + cursor = trivia_end + continue + if text[cursor] == ";": + return cursor + cursor += 1 + return None + + +def _has_aliased_include_import(text: str) -> bool: + """Detect lexical use-tree aliases that rename include! before invocation.""" + cursor = 0 + while cursor < len(text): + trivia_end = _skip_rust_trivia(text, cursor) + if trivia_end != cursor: + cursor = trivia_end + continue + + raw_end = _raw_string_end(text, cursor) + if raw_end is not None: + cursor = raw_end + continue + if text[cursor] == '"': + cursor = _quoted_string_end(text, cursor) + continue + if text[cursor] == "'": + char_end = _simple_char_literal_end(text, cursor) + if char_end is not None: + cursor = char_end + continue + + use_end = _rust_identifier_token_end(text, cursor, "use") + if use_end is None: + cursor += 1 + continue + statement_end = _rust_use_statement_end(text, use_end) + if statement_end is None: + return False + use_tree = text[use_end:statement_end] + use_cursor = 0 + while use_cursor < len(use_tree): + trivia_end = _skip_rust_trivia(use_tree, use_cursor) + if trivia_end != use_cursor: + use_cursor = trivia_end + continue + + raw_end = _raw_string_end(use_tree, use_cursor) + if raw_end is not None: + use_cursor = raw_end + continue + if use_tree[use_cursor] == '"': + use_cursor = _quoted_string_end(use_tree, use_cursor) + continue + if use_tree[use_cursor] == "'": + char_end = _simple_char_literal_end(use_tree, use_cursor) + if char_end is not None: + use_cursor = char_end + continue + + include_end = _rust_identifier_token_end( + use_tree, + use_cursor, + INCLUDE_TOKEN, + allow_raw=True, + ) + if include_end is None: + use_cursor += 1 + continue + include_cursor = _skip_rust_trivia(use_tree, include_end) + as_end = _rust_identifier_token_end(use_tree, include_cursor, "as") + if as_end is None: + use_cursor = include_end + continue + alias_start = _skip_rust_trivia(use_tree, as_end) + if alias_start >= len(use_tree): + use_cursor = include_end + continue + if use_tree[alias_start] == "_": + next_offset = alias_start + 1 + if next_offset >= len(use_tree) or not _rust_keyword_is_identifier_adjacent( + use_tree[next_offset] + ): + use_cursor = include_end + continue + return True + cursor = statement_end + 1 + return False + + +def _raw_string_end(text: str, offset: int) -> int | None: + """Return the end of a Rust raw string token beginning at offset, if present.""" + cursor = offset + if text.startswith(("br", "cr"), cursor): + cursor += 2 + elif cursor < len(text) and text[cursor] == "r": + cursor += 1 + else: + return None + + hashes_start = cursor + while cursor < len(text) and text[cursor] == "#": + cursor += 1 + if cursor >= len(text) or text[cursor] != '"': + return None + + hashes = text[hashes_start:cursor] + closing = '"' + hashes + end = text.find(closing, cursor + 1) + if end < 0: + raise AssertionError("unterminated Rust raw string in source attribute") + return end + len(closing) + + +def _quoted_string_end(text: str, offset: int) -> int: + """Return the end of a conventional Rust string token beginning with a quote.""" + index = offset + 1 + escaped = False + while index < len(text): + char = text[index] + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + return index + 1 + index += 1 + raise AssertionError("unterminated Rust string in source attribute") + + +def _simple_char_literal_end(text: str, offset: int) -> int | None: + """Skip a simple Rust character literal while leaving lifetimes untouched.""" + if offset + 2 < len(text) and text[offset + 2] == "'": + return offset + 3 + if offset + 1 >= len(text) or text[offset + 1] != "\\": + return None + + index = offset + 2 + while index < len(text): + if text[index] == "'": + return index + 1 + if text[index] == "\n": + return None + index += 1 + return None + + +def _next_rust_attribute_marker(text: str, offset: int) -> int | None: + """Find the next lexical `#` outside Rust comments and string/character literals.""" + cursor = offset + while cursor < len(text): + trivia_end = _skip_rust_trivia(text, cursor) + if trivia_end != cursor: + cursor = trivia_end + continue + + raw_end = _raw_string_end(text, cursor) + if raw_end is not None: + cursor = raw_end + continue + if text[cursor] == '"': + cursor = _quoted_string_end(text, cursor) + continue + if text[cursor] == "'": + char_end = _simple_char_literal_end(text, cursor) + if char_end is not None: + cursor = char_end + continue + if text[cursor] == "#": + return cursor + cursor += 1 + return None + + +def _rust_attribute_bodies(text: str) -> list[str]: + """Extract balanced Rust attribute token trees while respecting lexical trivia and literals.""" + bodies: list[str] = [] + search_from = 0 + while True: + marker = _next_rust_attribute_marker(text, search_from) + if marker is None: + break + + cursor = _skip_rust_trivia(text, marker + 1) + if cursor < len(text) and text[cursor] == "!": + cursor = _skip_rust_trivia(text, cursor + 1) + if cursor >= len(text) or text[cursor] != "[": + search_from = marker + 1 + continue + + body_start = cursor + 1 + depth = 1 + cursor = body_start + while cursor < len(text): + trivia_end = _skip_rust_trivia(text, cursor) + if trivia_end != cursor: + cursor = trivia_end + continue + + raw_end = _raw_string_end(text, cursor) + if raw_end is not None: + cursor = raw_end + continue + if text[cursor] == '"': + cursor = _quoted_string_end(text, cursor) + continue + if text[cursor] == "'": + char_end = _simple_char_literal_end(text, cursor) + if char_end is not None: + cursor = char_end + continue + + if text[cursor] == "[": + depth += 1 + elif text[cursor] == "]": + depth -= 1 + if depth == 0: + bodies.append(text[body_start:cursor]) + search_from = cursor + 1 + break + cursor += 1 + else: + raise AssertionError("unterminated Rust attribute in production source") + + return bodies + + +def _has_path_meta(attribute_body: str) -> bool: + """Return whether a lexical attribute meta item selects a Rust module source path.""" + cursor = 0 + while cursor < len(attribute_body): + trivia_end = _skip_rust_trivia(attribute_body, cursor) + if trivia_end != cursor: + cursor = trivia_end + continue + + raw_end = _raw_string_end(attribute_body, cursor) + if raw_end is not None: + cursor = raw_end + continue + if attribute_body[cursor] == '"': + cursor = _quoted_string_end(attribute_body, cursor) + continue + if attribute_body[cursor] == "'": + char_end = _simple_char_literal_end(attribute_body, cursor) + if char_end is not None: + cursor = char_end + continue + + path_end = _rust_identifier_token_end( + attribute_body, + cursor, + "path", + allow_raw=True, + ) + if path_end is None: + cursor += 1 + continue + equals = _skip_rust_trivia(attribute_body, path_end) + if equals < len(attribute_body) and attribute_body[equals] == "=": + return True + cursor = path_end + return False + + +def _is_under_any_default_src(source: pathlib.Path, src_roots: list[pathlib.Path]) -> bool: + """Return whether Cargo source discovery already reviews every sibling module under this source root.""" + resolved = source.resolve() + for src_root in src_roots: + try: + resolved.relative_to(src_root) + return True + except ValueError: + continue + return False + + +def _assert_no_unmodeled_rust_source_indirection(root: pathlib.Path) -> None: + """Fail closed when reviewed Rust source can pull unmodeled executable source bytes.""" + discovered_path_attributes: set[tuple[str, str]] = set() + default_src_roots = [ + (manifest.parent / "src").resolve() + for manifest in boundary._production_package_manifests(root) + ] + + for source in boundary._workspace_production_sources(root): + text = source.read_text(encoding="utf-8") + relative = source.relative_to(root).as_posix() + + if _has_include_macro(text) or _has_aliased_include_import(text): + raise AssertionError( + f"Rust include! source indirection requires an explicit provenance contract: {relative}" + ) + + # A custom target root is outside the canonical src/**/*.rs sibling closure. Until + # compiler-derived source inputs replace this guard, any lexical `mod` token is an + # intentionally conservative provenance stop: comments/trivia, raw/Unicode names, + # visibility spellings, and inline-vs-outlined grammar must not create bypasses. + if not _is_under_any_default_src(source, default_src_roots) and _has_custom_target_mod_token(text): + raise AssertionError( + "Rust module source indirection from a custom Cargo target requires an explicit " + f"provenance contract: {relative}" + ) + + for attribute_body in _rust_attribute_bodies(text): + if _has_path_meta(attribute_body): + discovered_path_attributes.add( + (relative, _normalized_attribute_body(attribute_body)) + ) + + unexpected = discovered_path_attributes - APPROVED_RUST_PATH_ATTRIBUTES + if unexpected: + raise AssertionError( + "Rust path attribute requires an explicit exact-tree provenance review: " + f"{sorted(unexpected)}" + ) + + stale = APPROVED_RUST_PATH_ATTRIBUTES - discovered_path_attributes + if root.resolve() == ROOT.resolve() and stale: + raise AssertionError( + f"Rust path-attribute allowlist preapproves absent production surfaces: {sorted(stale)}" + ) + + +class BrowserSessionRustSourceIndirectionContractTests(unittest.TestCase): + """Keep Rust source indirection inside the exact-head production-source provenance boundary.""" + + def _workspace_with_source(self, source_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (adapter / "src/lib.rs").write_text(source_text, encoding="utf-8") + return root + + def _custom_target_workspace(self, source_text: str, module_file: str) -> pathlib.Path: + """Create a custom-target crate whose outlined module is outside Cargo's default src tree.""" + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "runtime").mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n' + '[lib]\npath = "runtime/lifecycle_adapter.rs"\n', + encoding="utf-8", + ) + (adapter / "runtime/lifecycle_adapter.rs").write_text(source_text, encoding="utf-8") + (adapter / f"runtime/{module_file}").write_text( + "pub fn helper_surface() {}\n", + encoding="utf-8", + ) + return root + + def _assert_include_form_fails_closed(self, source_text: str) -> None: + root = self._workspace_with_source(source_text) + (root / "adapter/generated_adapter.rs").write_text( + "pub fn generated_adapter_surface() {}\n", + encoding="utf-8", + ) + + with self.assertRaisesRegex(AssertionError, "Rust include! source indirection"): + _assert_no_unmodeled_rust_source_indirection(root) + + def test_current_production_sources_have_no_unmodeled_source_indirection(self) -> None: + _assert_no_unmodeled_rust_source_indirection(ROOT) + + def test_parenthesized_include_macro_fails_closed(self) -> None: + self._assert_include_form_fails_closed('include!("../generated_adapter.rs");\n') + + def test_braced_include_macro_fails_closed(self) -> None: + self._assert_include_form_fails_closed('include! { "../generated_adapter.rs" }\n') + + def test_bracketed_include_macro_fails_closed(self) -> None: + self._assert_include_form_fails_closed('include!["../generated_adapter.rs"];\n') + + def test_unicode_continuation_aliased_include_import_fails_closed(self) -> None: + self._assert_include_form_fails_closed( + 'use core::include as _\u0301;\n_\u0301!("../generated_adapter.rs");\n' + ) + + def test_exact_underscore_include_import_is_not_callable_alias(self) -> None: + root = self._workspace_with_source( + 'use core::include as _;\npub fn reviewed_surface() {}\n' + ) + _assert_no_unmodeled_rust_source_indirection(root) + + def test_bare_module_from_custom_target_fails_closed(self) -> None: + root = self._custom_target_workspace( + "mod helper;\npub fn lifecycle_adapter_surface() {}\n", + "helper.rs", + ) + + with self.assertRaisesRegex(AssertionError, "Rust module source indirection"): + _assert_no_unmodeled_rust_source_indirection(root) + + def test_raw_identifier_bare_module_from_custom_target_fails_closed(self) -> None: + root = self._custom_target_workspace( + "mod r#type;\npub fn lifecycle_adapter_surface() {}\n", + "type.rs", + ) + + with self.assertRaisesRegex(AssertionError, "Rust module source indirection"): + _assert_no_unmodeled_rust_source_indirection(root) + + def test_unicode_identifier_bare_module_from_custom_target_fails_closed(self) -> None: + root = self._custom_target_workspace( + "mod 관찰;\npub fn lifecycle_adapter_surface() {}\n", + "관찰.rs", + ) + + with self.assertRaisesRegex(AssertionError, "Rust module source indirection"): + _assert_no_unmodeled_rust_source_indirection(root) + + def test_comment_trivia_after_mod_from_custom_target_fails_closed(self) -> None: + root = self._custom_target_workspace( + "mod /* reviewed trivia */ helper;\npub fn lifecycle_adapter_surface() {}\n", + "helper.rs", + ) + + with self.assertRaisesRegex(AssertionError, "Rust module source indirection"): + _assert_no_unmodeled_rust_source_indirection(root) + + def test_bare_module_under_default_src_uses_existing_source_closure(self) -> None: + root = self._workspace_with_source("mod nested;\npub fn adapter_surface() {}\n") + (root / "adapter/src/nested.rs").write_text( + "pub fn nested_adapter_surface() {}\n", + encoding="utf-8", + ) + + _assert_no_unmodeled_rust_source_indirection(root) + + def test_new_path_attribute_fails_closed_even_when_target_is_in_tree(self) -> None: + root = self._workspace_with_source('#[path = "nested.rs"]\nmod nested;\n') + (root / "adapter/src/nested.rs").write_text( + "pub fn nested_adapter_surface() {}\n", + encoding="utf-8", + ) + + with self.assertRaisesRegex(AssertionError, "Rust path attribute requires"): + _assert_no_unmodeled_rust_source_indirection(root) + + def test_cfg_attr_generated_path_attribute_fails_closed(self) -> None: + root = self._workspace_with_source( + '#[cfg_attr(unix, path = "unix_adapter.rs")]\nmod platform_adapter;\n' + ) + (root / "adapter/src/unix_adapter.rs").write_text( + "pub fn unix_adapter_surface() {}\n", + encoding="utf-8", + ) + + with self.assertRaisesRegex(AssertionError, "Rust path attribute requires"): + _assert_no_unmodeled_rust_source_indirection(root) + + def test_parent_traversal_path_attribute_fails_closed(self) -> None: + root = self._workspace_with_source('#[path = "../shared_adapter.rs"]\nmod shared_adapter;\n') + (root / "adapter/shared_adapter.rs").write_text( + "pub fn shared_adapter_surface() {}\n", + encoding="utf-8", + ) + + with self.assertRaisesRegex(AssertionError, "Rust path attribute requires"): + _assert_no_unmodeled_rust_source_indirection(root) + + def test_commented_path_attribute_text_is_not_source_indirection(self) -> None: + root = self._workspace_with_source( + '// #[path = "review_bypass.rs"]\n' + 'pub fn reviewed_surface() {}\n' + ) + + _assert_no_unmodeled_rust_source_indirection(root) + + def test_string_containing_path_attribute_text_is_not_source_indirection(self) -> None: + root = self._workspace_with_source( + 'pub const NOTE: &str = "#[path = \\"review_bypass.rs\\"]";\n' + ) + + _assert_no_unmodeled_rust_source_indirection(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rust_use_keyword_identifier_boundary_contract.py b/tests/test_browser_session_rust_use_keyword_identifier_boundary_contract.py new file mode 100644 index 000000000..7b8feb802 --- /dev/null +++ b/tests/test_browser_session_rust_use_keyword_identifier_boundary_contract.py @@ -0,0 +1,60 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE_INDIRECTION_TEST = ROOT / "tests/test_browser_session_rust_source_indirection_contract.py" + +spec = importlib.util.spec_from_file_location( + "browser_session_rust_source_indirection_contract", + SOURCE_INDIRECTION_TEST, +) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Rust source-indirection contract") +source_indirection = importlib.util.module_from_spec(spec) +spec.loader.exec_module(source_indirection) + + +class BrowserSessionRustUseKeywordIdentifierBoundaryContractTests(unittest.TestCase): + """Keep Rust `use` keyword recognition aligned with Rust XID identifier boundaries.""" + + def _workspace_with_source(self, source_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + (adapter / "src/lib.rs").write_text(source_text, encoding="utf-8") + return root + + def test_xid_continue_before_use_inside_macro_tokens_is_not_a_use_declaration(self) -> None: + root = self._workspace_with_source( + "macro_rules! tokens { ($($tt:tt)*) => {}; }\n" + "tokens!(a\u0301use core::include as hidden_include);\n" + "pub fn reviewed_surface() {}\n" + ) + + source_indirection._assert_no_unmodeled_rust_source_indirection(root) + + def test_real_aliased_include_import_remains_a_provenance_stop(self) -> None: + root = self._workspace_with_source( + "use core::include as hidden_include;\n" + "pub fn reviewed_surface() {}\n" + ) + + with self.assertRaisesRegex(AssertionError, "Rust include! source indirection"): + source_indirection._assert_no_unmodeled_rust_source_indirection(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rustdoc_doc_meta_input_authority_contract.py b/tests/test_browser_session_rustdoc_doc_meta_input_authority_contract.py new file mode 100644 index 000000000..ec497c6a7 --- /dev/null +++ b/tests/test_browser_session_rustdoc_doc_meta_input_authority_contract.py @@ -0,0 +1,63 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionRustdocDocMetaInputAuthorityContractTests(unittest.TestCase): + """Keep rustdoc cross-crate metadata inputs inside reviewed documentation provenance.""" + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def _assert_documentation_input_fails_closed(self, config_text: str) -> None: + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "rustdocflags:documentation input"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustdocflags_read_doc_meta_dir_fails_closed(self) -> None: + self._assert_documentation_input_fails_closed( + '[build]\nrustdocflags = ["-Z", "unstable-options", "--read-doc-meta-dir", "tools/review-bypass-doc-meta"]\n' + ) + + def test_target_rustdocflags_equals_read_doc_meta_dir_fails_closed(self) -> None: + self._assert_documentation_input_fails_closed( + "[target.'cfg(unix)']\nrustdocflags = [\"-Z\", \"unstable-options\", \"--read-doc-meta-dir=tools/review-bypass-doc-meta\"]\n" + ) + + def test_write_doc_meta_dir_output_remains_allowed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustdocflags = ["-Z", "unstable-options", "--write-doc-meta-dir", "target/reviewed-doc-meta"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rustdoc_doctest_build_arg_authority_contract.py b/tests/test_browser_session_rustdoc_doctest_build_arg_authority_contract.py new file mode 100644 index 000000000..58597945f --- /dev/null +++ b/tests/test_browser_session_rustdoc_doctest_build_arg_authority_contract.py @@ -0,0 +1,70 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionRustdocDoctestBuildArgAuthorityContractTests(unittest.TestCase): + """Keep doctest compiler arguments inside the reviewed Cargo execution/input boundary.""" + + def _assert_fails_closed(self, config_text: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, "rustdocflags:doctest compiler authority"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustdocflags_forwarded_sysroot_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-Z", "unstable-options", "--doctest-build-arg=--sysroot=tools/review-bypass-sysroot"]\n' + ) + + def test_target_rustdocflags_forwarded_linker_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustdocflags = [\"-Zunstable-options\", \"--doctest-build-arg\", \"-C\", \"--doctest-build-arg\", \"linker=tools/review-bypass-linker\"]\n" + ) + + def test_build_rustdocflags_forwarded_codegen_backend_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-Zunstable-options", "--doctest-build-arg=-Zcodegen-backend=tools/review-bypass-codegen.so"]\n' + ) + + def test_non_authority_doctest_build_args_remain_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustdocflags = ["-Zunstable-options", "--doctest-build-arg=--cfg=originweave_reviewed", "--doctest-build-arg=-Copt-level=2"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rustdoc_doctest_execution_authority_contract.py b/tests/test_browser_session_rustdoc_doctest_execution_authority_contract.py new file mode 100644 index 000000000..8f4bb1f7d --- /dev/null +++ b/tests/test_browser_session_rustdoc_doctest_execution_authority_contract.py @@ -0,0 +1,75 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +def _workspace_with_config(config_text: str) -> tuple[tempfile.TemporaryDirectory[str], pathlib.Path]: + directory = tempfile.TemporaryDirectory() + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return directory, root + + +class BrowserSessionRustdocDoctestExecutionAuthorityContractTests(unittest.TestCase): + """Keep Git-owned rustdoc doctest execution programs inside reviewed provenance.""" + + def _assert_fails_closed(self, config_text: str) -> None: + directory, root = _workspace_with_config(config_text) + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(AssertionError, "rustdocflags:doctest execution"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustdocflags_doctest_runtool_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["--test-runtool", "tools/review-bypass-runtool"]\n' + ) + + def test_target_rustdocflags_doctest_runtool_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustdocflags = [\"--test-runtool=tools/review-bypass-runtool\"]\n" + ) + + def test_build_rustdocflags_doctest_builder_fails_closed(self) -> None: + self._assert_fails_closed( + '[build]\nrustdocflags = ["-Z", "unstable-options", "--test-builder", "tools/review-bypass-rustc"]\n' + ) + + def test_target_rustdocflags_doctest_builder_wrapper_fails_closed(self) -> None: + self._assert_fails_closed( + "[target.'cfg(unix)']\nrustdocflags = [\"-Zunstable-options\", \"--test-builder-wrapper=tools/review-bypass-wrapper\"]\n" + ) + + def test_non_execution_doctest_arguments_remain_allowed(self) -> None: + directory, root = _workspace_with_config( + '[build]\nrustdocflags = ["--test-args", "ignored", "--test-run-directory=target/doctest"]\n' + ) + self.addCleanup(directory.cleanup) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rustdoc_external_input_contract.py b/tests/test_browser_session_rustdoc_external_input_contract.py new file mode 100644 index 000000000..48e5ddbc0 --- /dev/null +++ b/tests/test_browser_session_rustdoc_external_input_contract.py @@ -0,0 +1,63 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionRustdocExternalInputContractTests(unittest.TestCase): + """Keep rustdoc-selected external crate and native-library inputs reviewed.""" + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def _assert_external_input_fails_closed(self, config_text: str) -> None: + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustdocflags_extern_input_fails_closed(self) -> None: + self._assert_external_input_fails_closed( + '[build]\nrustdocflags = ["--extern", "review_bypass=tools/libreview_bypass.rlib"]\n' + ) + + def test_target_rustdocflags_library_search_path_fails_closed(self) -> None: + self._assert_external_input_fails_closed( + "[target.'cfg(unix)']\nrustdocflags = [\"-Lnative=tools/review-bypass\"]\n" + ) + + def test_unrelated_rustdocflags_remain_allowed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustdocflags = ["--document-private-items", "--cfg", "docsrs"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rustdoc_library_path_input_authority_contract.py b/tests/test_browser_session_rustdoc_library_path_input_authority_contract.py new file mode 100644 index 000000000..263080efc --- /dev/null +++ b/tests/test_browser_session_rustdoc_library_path_input_authority_contract.py @@ -0,0 +1,63 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionRustdocLibraryPathInputAuthorityContractTests(unittest.TestCase): + """Keep rustdoc dependency search paths inside reviewed documentation provenance.""" + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def _assert_external_input_fails_closed(self, config_text: str) -> None: + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "rustdocflags:external link input"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustdocflags_library_path_fails_closed(self) -> None: + self._assert_external_input_fails_closed( + '[build]\nrustdocflags = ["--library-path", "tools/review-bypass-deps"]\n' + ) + + def test_target_rustdocflags_equals_library_path_fails_closed(self) -> None: + self._assert_external_input_fails_closed( + "[target.'cfg(unix)']\nrustdocflags = [\"--library-path=tools/review-bypass-deps\"]\n" + ) + + def test_unrelated_rustdocflag_remains_allowed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustdocflags = ["--document-private-items", "--cfg", "docsrs"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_rustdoc_render_input_authority_contract.py b/tests/test_browser_session_rustdoc_render_input_authority_contract.py new file mode 100644 index 000000000..f582a2cca --- /dev/null +++ b/tests/test_browser_session_rustdoc_render_input_authority_contract.py @@ -0,0 +1,107 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionRustdocRenderInputAuthorityContractTests(unittest.TestCase): + """Keep rustdoc-rendered file inputs inside the reviewed documentation provenance boundary.""" + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + root = pathlib.Path(directory.name) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + def _assert_render_input_fails_closed(self, config_text: str) -> None: + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "rustdocflags:documentation input"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_build_rustdocflags_render_file_inputs_fail_closed(self) -> None: + selectors = ( + ("--html-in-header", "tools/review-bypass-header.html"), + ("--html-before-content", "tools/review-bypass-before.html"), + ("--html-after-content", "tools/review-bypass-after.html"), + ("--markdown-before-content", "tools/review-bypass-before.md"), + ("--markdown-after-content", "tools/review-bypass-after.md"), + ("--extend-css", "tools/review-bypass.css"), + ("--theme", "tools/review-bypass-theme.css"), + ("--check-theme", "tools/review-bypass-theme.css"), + ("-e", "tools/review-bypass-short.css"), + ) + for selector, path in selectors: + with self.subTest(selector=selector): + self._assert_render_input_fails_closed( + f'[build]\nrustdocflags = ["{selector}", "{path}"]\n' + ) + + def test_build_rustdocflags_unstable_index_page_input_fails_closed(self) -> None: + self._assert_render_input_fails_closed( + '[build]\nrustdocflags = ["-Z", "unstable-options", "--index-page", "tools/review-bypass-index.md"]\n' + ) + + def test_target_rustdocflags_equals_index_page_input_fails_closed(self) -> None: + self._assert_render_input_fails_closed( + "[target.'cfg(unix)']\nrustdocflags = [\"-Z\", \"unstable-options\", \"--index-page=tools/review-bypass-index.md\"]\n" + ) + + def test_target_rustdocflags_equals_render_file_input_fails_closed(self) -> None: + self._assert_render_input_fails_closed( + "[target.'cfg(unix)']\nrustdocflags = [\"--html-before-content=tools/review-bypass-before.html\"]\n" + ) + + def test_build_rustdocflags_with_examples_input_fails_closed(self) -> None: + self._assert_render_input_fails_closed( + '[build]\nrustdocflags = ["-Z", "unstable-options", "--with-examples", "tools/review-bypass.calls"]\n' + ) + + def test_target_rustdocflags_equals_with_examples_input_fails_closed(self) -> None: + self._assert_render_input_fails_closed( + "[target.'cfg(unix)']\nrustdocflags = [\"-Z\", \"unstable-options\", \"--with-examples=tools/review-bypass.calls\"]\n" + ) + + def test_host_rustdocflags_with_examples_input_fails_closed(self) -> None: + self._assert_render_input_fails_closed( + '[host]\nrustdocflags = ["-Z", "unstable-options", "--with-examples", "tools/review-bypass.calls"]\n' + ) + + def test_scrape_examples_output_path_remains_output_only(self) -> None: + root = self._workspace_with_config( + '[build]\nrustdocflags = ["-Z", "unstable-options", "--scrape-examples-output-path", "target/reviewed.calls"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_unrelated_rustdoc_render_flags_remain_allowed(self) -> None: + root = self._workspace_with_config( + '[build]\nrustdocflags = ["--document-private-items", "--default-theme", "ayu", "--markdown-css", "reviewed.css"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_sysroot_input_contract.py b/tests/test_browser_session_sysroot_input_contract.py new file mode 100644 index 000000000..49be787b1 --- /dev/null +++ b/tests/test_browser_session_sysroot_input_contract.py @@ -0,0 +1,61 @@ +import importlib.util +import pathlib +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +AUTHORITY_TEST = ROOT / "tests/test_browser_session_cargo_compiler_authority_contract.py" + +spec = importlib.util.spec_from_file_location("browser_session_cargo_compiler_authority", AUTHORITY_TEST) +if spec is None or spec.loader is None: + raise RuntimeError("unable to load Browser Session Cargo compiler-authority contract") +authority = importlib.util.module_from_spec(spec) +spec.loader.exec_module(authority) + + +class BrowserSessionSysrootInputContractTests(unittest.TestCase): + """Keep repository-selected Rust sysroots inside reviewed compiler-input provenance.""" + + def test_repository_sysroot_overrides_fail_closed(self) -> None: + hostile_configs = ( + '[build]\nrustflags = ["--sysroot", "tools/review-bypass-sysroot"]\n', + '[build]\nrustflags = ["--sysroot=tools/review-bypass-sysroot"]\n', + "[target.'cfg(unix)']\nrustflags = [\"--sysroot\", \"tools/review-bypass-sysroot\"]\n", + '[build]\nrustdocflags = ["--sysroot=tools/review-bypass-sysroot"]\n', + "[target.'cfg(unix)']\nrustdocflags = [\"--sysroot\", \"tools/review-bypass-sysroot\"]\n", + ) + for config_text in hostile_configs: + with self.subTest(config_text=config_text): + root = self._workspace_with_config(config_text) + with self.assertRaisesRegex(AssertionError, "Cargo .*execution override"): + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def test_unrelated_rust_flags_do_not_extend_sysroot_input_authority(self) -> None: + root = self._workspace_with_config( + '[build]\nrustflags = ["--remap-path-prefix", "src=/workspace/src"]\n' + ) + authority._assert_no_repository_cargo_compiler_execution_overrides(root) + + def _workspace_with_config(self, config_text: str) -> pathlib.Path: + directory = self.enterContext(tempfile.TemporaryDirectory()) + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["adapter"]\nresolver = "3"\n', + encoding="utf-8", + ) + adapter = root / "adapter" + (adapter / "src").mkdir(parents=True) + (adapter / "src/lib.rs").write_text("pub fn adapter_surface() {}\n", encoding="utf-8") + (adapter / "Cargo.toml").write_text( + '[package]\nname = "adapter"\nversion = "0.1.0"\nedition = "2024"\n', + encoding="utf-8", + ) + cargo = root / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text(config_text, encoding="utf-8") + return root + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_trusted_adapter_boundary.py b/tests/test_browser_session_trusted_adapter_boundary.py new file mode 100644 index 000000000..f63445fdc --- /dev/null +++ b/tests/test_browser_session_trusted_adapter_boundary.py @@ -0,0 +1,609 @@ +import pathlib +import re +import tempfile +import tomllib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +ROOT_CARGO = ROOT / "Cargo.toml" +BROWSER_SESSION_CARGO = ROOT / "crates/originweave-browser-session/Cargo.toml" +THREAT_MODEL = ROOT / "docs/THREAT_MODEL.md" +DOSSIER = ROOT / "docs/traceability/browser-session-trusted-adapter-boundary.md" +BROWSER_SESSION_SOURCE_ROOT = "crates/originweave-browser-session/src/" + +# Any production reference to the lifecycle SPI outside the Browser Session owner is an explicit +# review surface. Allow only production surfaces that exist and were reviewed on this exact branch. +APPROVED_PRODUCTION_PORT_REFERENCES: set[str] = set() +APPROVED_BROWSER_SESSION_DEPENDENCIES: set[str] = set() + +PORT_REFERENCE = re.compile(r"\bDisposableContextPort\b") +LIFECYCLE_BINDING = re.compile(r"\bbind_lifecycle_port\b") +BROWSER_SESSION_DEPENDENCY = re.compile(r"\boriginweave-browser-session\b") +BROWSER_SESSION_PACKAGE = "originweave-browser-session" + + +def _has_port_reference(text: str) -> bool: + return PORT_REFERENCE.search(text) is not None + + +def _has_lifecycle_binding(text: str) -> bool: + return LIFECYCLE_BINDING.search(text) is not None + + +def _has_browser_session_dependency(text: str) -> bool: + return BROWSER_SESSION_DEPENDENCY.search(text) is not None + + +def _dependency_package_name( + dependency_name: str, + dependency_spec: object, + workspace_dependencies: dict[str, object], +) -> str: + if not isinstance(dependency_spec, dict): + return dependency_name + + package = dependency_spec.get("package") + if isinstance(package, str): + return package + + if dependency_spec.get("workspace") is True: + workspace_spec = workspace_dependencies.get(dependency_name) + if isinstance(workspace_spec, dict): + workspace_package = workspace_spec.get("package") + if isinstance(workspace_package, str): + return workspace_package + if workspace_spec is not None: + return dependency_name + + return dependency_name + + +def _manifest_dependency_sections(manifest: dict[str, object]) -> list[dict[str, object]]: + sections: list[dict[str, object]] = [] + dependencies = manifest.get("dependencies") + if isinstance(dependencies, dict): + sections.append(dependencies) + + targets = manifest.get("target") + if isinstance(targets, dict): + for target in targets.values(): + if not isinstance(target, dict): + continue + target_dependencies = target.get("dependencies") + if isinstance(target_dependencies, dict): + sections.append(target_dependencies) + + return sections + + +def _manifest_build_dependency_sections(manifest: dict[str, object]) -> list[dict[str, object]]: + """Return build dependency sections that can influence generated production code.""" + sections: list[dict[str, object]] = [] + build_dependencies = manifest.get("build-dependencies") + if isinstance(build_dependencies, dict): + sections.append(build_dependencies) + + targets = manifest.get("target") + if isinstance(targets, dict): + for target in targets.values(): + if not isinstance(target, dict): + continue + target_build_dependencies = target.get("build-dependencies") + if isinstance(target_build_dependencies, dict): + sections.append(target_build_dependencies) + + return sections + + +def _assert_no_production_build_surfaces(root: pathlib.Path, manifest: pathlib.Path) -> None: + """Fail closed on Cargo build surfaces until generated-source provenance is modeled.""" + parsed = tomllib.loads(manifest.read_text(encoding="utf-8")) + build_dependency_sections = _manifest_build_dependency_sections(parsed) + if any(section for section in build_dependency_sections): + relative = manifest.relative_to(root).as_posix() + raise AssertionError( + f"production Cargo build dependencies require an explicit trusted-adapter contract: {relative}" + ) + + package = parsed.get("package") + build_setting: object = None + if isinstance(package, dict): + build_setting = package.get("build") + + if build_setting is False: + return + if isinstance(build_setting, str) and build_setting: + relative = manifest.relative_to(root).as_posix() + raise AssertionError( + f"production Cargo build script requires an explicit trusted-adapter contract: {relative}" + ) + if build_setting is not None: + relative = manifest.relative_to(root).as_posix() + raise AssertionError( + f"unsupported Cargo package.build setting in production package: {relative}" + ) + + default_build_script = manifest.parent / "build.rs" + if default_build_script.exists() or default_build_script.is_symlink(): + relative = manifest.relative_to(root).as_posix() + raise AssertionError( + f"production Cargo build script requires an explicit trusted-adapter contract: {relative}" + ) + + +def _assert_no_workspace_source_overrides(root_manifest_path: pathlib.Path) -> None: + """Fail closed on Cargo patch/replace surfaces until override provenance is modeled.""" + parsed = tomllib.loads(root_manifest_path.read_text(encoding="utf-8")) + for section_name in ("patch", "replace"): + section = parsed.get(section_name) + if isinstance(section, dict) and section: + raise AssertionError( + f"production Cargo source override requires an explicit trusted-adapter contract: [{section_name}]" + ) + + +def _assert_no_repository_cargo_config_source_overrides(root: pathlib.Path) -> None: + """Fail closed on Git-owned Cargo config surfaces that can alter dependency sources.""" + root_resolved = root.resolve() + config_paths: set[pathlib.Path] = set() + for pattern in (".cargo/config.toml", ".cargo/config"): + config_paths.update(root.rglob(pattern)) + + for config_path in sorted(config_paths): + resolved = config_path.resolve() + try: + resolved.relative_to(root_resolved) + except ValueError as exc: + raise AssertionError( + f"Cargo config escapes repository review root: {config_path.relative_to(root).as_posix()}" + ) from exc + if not resolved.is_file(): + raise AssertionError( + f"Cargo config is missing: {config_path.relative_to(root).as_posix()}" + ) + + parsed = tomllib.loads(resolved.read_text(encoding="utf-8")) + paths = parsed.get("paths") + patch = parsed.get("patch") + sources = parsed.get("source") + if ( + (isinstance(paths, list) and bool(paths)) + or (isinstance(patch, dict) and bool(patch)) + or (isinstance(sources, dict) and bool(sources)) + ): + relative = config_path.relative_to(root).as_posix() + raise AssertionError( + "production Cargo config source override requires an explicit trusted-adapter contract: " + f"{relative}" + ) + + +def _manifest_links_browser_session(member_text: str, workspace_text: str) -> bool: + member = tomllib.loads(member_text) + workspace_manifest = tomllib.loads(workspace_text) + workspace = workspace_manifest.get("workspace") + workspace_dependencies: dict[str, object] = {} + if isinstance(workspace, dict): + declared = workspace.get("dependencies") + if isinstance(declared, dict): + workspace_dependencies = declared + + for section in _manifest_dependency_sections(member): + for dependency_name, dependency_spec in section.items(): + if ( + _dependency_package_name( + dependency_name, + dependency_spec, + workspace_dependencies, + ) + == BROWSER_SESSION_PACKAGE + ): + return True + return False + + +def _workspace_member_manifests(root: pathlib.Path) -> list[pathlib.Path]: + """Return every explicitly reviewed Cargo workspace package manifest.""" + root_manifest_path = root / "Cargo.toml" + root_manifest = tomllib.loads(root_manifest_path.read_text(encoding="utf-8")) + workspace = root_manifest.get("workspace") + if not isinstance(workspace, dict): + raise AssertionError("repository root must declare a Cargo workspace") + members = workspace.get("members") + if not isinstance(members, list): + raise AssertionError("Cargo workspace members must be an explicit reviewed list") + + root_resolved = root.resolve() + manifests: set[pathlib.Path] = set() + if isinstance(root_manifest.get("package"), dict): + manifests.add(root_manifest_path) + + for member in members: + if not isinstance(member, str) or not member: + raise AssertionError("Cargo workspace member paths must be non-empty strings") + if any(token in member for token in ("*", "?", "[")): + raise AssertionError( + "Cargo workspace member globs require an explicit trusted-adapter contract update" + ) + manifest = (root / member / "Cargo.toml").resolve() + try: + manifest.relative_to(root_resolved) + except ValueError as exc: + raise AssertionError( + f"Cargo workspace member escapes repository review root: {member}" + ) from exc + if not manifest.is_file(): + raise AssertionError(f"workspace member manifest is missing: {member}/Cargo.toml") + manifests.add(manifest) + return sorted(manifests) + + +def _in_repository_path_dependency( + root: pathlib.Path, + manifest: pathlib.Path, + dependency_name: str, + dependency_spec: object, + workspace_dependencies: dict[str, object], +) -> pathlib.Path | None: + spec = dependency_spec + base = manifest.parent + if isinstance(spec, dict) and spec.get("workspace") is True: + spec = workspace_dependencies.get(dependency_name) + base = root + if not isinstance(spec, dict): + return None + + declared_path = spec.get("path") + if not isinstance(declared_path, str) or not declared_path: + return None + + root_resolved = root.resolve() + candidate = (base / declared_path / "Cargo.toml").resolve() + try: + candidate.relative_to(root_resolved) + except ValueError as exc: + raise AssertionError( + f"production Cargo path dependency escapes repository review root: {declared_path}" + ) from exc + if not candidate.is_file(): + raise AssertionError(f"production Cargo path dependency manifest is missing: {declared_path}") + return candidate + + +def _production_package_manifests(root: pathlib.Path) -> list[pathlib.Path]: + """Return workspace packages plus recursive in-repository production path dependencies.""" + root_manifest_path = root / "Cargo.toml" + root_manifest = tomllib.loads(root_manifest_path.read_text(encoding="utf-8")) + _assert_no_workspace_source_overrides(root_manifest_path) + _assert_no_repository_cargo_config_source_overrides(root) + workspace = root_manifest.get("workspace") + workspace_dependencies: dict[str, object] = {} + if isinstance(workspace, dict): + declared = workspace.get("dependencies") + if isinstance(declared, dict): + workspace_dependencies = declared + + manifests = set(_workspace_member_manifests(root)) + pending = list(manifests) + while pending: + manifest = pending.pop() + _assert_no_production_build_surfaces(root, manifest) + parsed = tomllib.loads(manifest.read_text(encoding="utf-8")) + for section in _manifest_dependency_sections(parsed): + for dependency_name, dependency_spec in section.items(): + candidate = _in_repository_path_dependency( + root, + manifest, + dependency_name, + dependency_spec, + workspace_dependencies, + ) + if candidate is not None and candidate not in manifests: + manifests.add(candidate) + pending.append(candidate) + return sorted(manifests) + + +def _declared_production_target_sources( + root: pathlib.Path, + manifest: pathlib.Path, +) -> set[pathlib.Path]: + """Return explicitly configured library and binary sources under the repository review root.""" + parsed = tomllib.loads(manifest.read_text(encoding="utf-8")) + declared_paths: list[str] = [] + + library = parsed.get("lib") + if isinstance(library, dict): + library_path = library.get("path") + if isinstance(library_path, str) and library_path: + declared_paths.append(library_path) + + binaries = parsed.get("bin") + if isinstance(binaries, list): + for binary in binaries: + if not isinstance(binary, dict): + continue + binary_path = binary.get("path") + if isinstance(binary_path, str) and binary_path: + declared_paths.append(binary_path) + + root_resolved = root.resolve() + sources: set[pathlib.Path] = set() + for declared_path in declared_paths: + candidate = (manifest.parent / declared_path).resolve() + try: + candidate.relative_to(root_resolved) + except ValueError as exc: + raise AssertionError( + f"production Cargo target source escapes repository review root: {declared_path}" + ) from exc + if not candidate.is_file(): + raise AssertionError(f"declared production Cargo target source is missing: {declared_path}") + sources.add(candidate) + return sources + + +def _workspace_production_sources(root: pathlib.Path) -> list[pathlib.Path]: + """Return the canonical production Rust source closure reviewed by the TCB contract.""" + root_resolved = root.resolve() + sources: set[pathlib.Path] = set() + for manifest in _production_package_manifests(root): + sources.update(manifest.parent.glob("src/**/*.rs")) + sources.update(_declared_production_target_sources(root, manifest)) + + reviewed: list[pathlib.Path] = [] + for source in sources: + resolved = source.resolve() + try: + resolved.relative_to(root_resolved) + except ValueError as exc: + raise AssertionError( + f"production Cargo source escapes repository review root: {source}" + ) from exc + if not resolved.is_file(): + raise AssertionError(f"production Cargo source is missing: {source}") + reviewed.append(source) + return sorted(reviewed) + + +class BrowserSessionTrustedAdapterBoundaryTests(unittest.TestCase): + """Keep the privileged lifecycle adapter inside the reviewed product TCB.""" + + def test_browser_session_crate_is_internal_and_zone_c_is_trusted(self) -> None: + cargo = BROWSER_SESSION_CARGO.read_text(encoding="utf-8") + threat_model = THREAT_MODEL.read_text(encoding="utf-8") + + self.assertRegex(cargo, r"(?m)^publish\s*=\s*false\s*$") + self.assertIn( + "Zone C — Chromium browser process and privileged adapters", + threat_model, + ) + self.assertIn("trusted browser integration code", threat_model) + + def test_trusted_adapter_dossier_states_the_supported_security_boundary(self) -> None: + dossier = DOSSIER.read_text(encoding="utf-8") + + for required in ( + "trusted computing base", + "DisposableContextPort", + "publish = false", + "supply-chain compromise", + "caller-selected production adapter", + "request/completion correlation is not adapter authentication", + ): + self.assertIn(required, dossier) + + def test_production_disposable_context_port_references_are_allowlisted(self) -> None: + discovered = set() + for path in _workspace_production_sources(ROOT): + relative = path.relative_to(ROOT).as_posix() + if relative.startswith(BROWSER_SESSION_SOURCE_ROOT): + continue + text = path.read_text(encoding="utf-8") + if _has_port_reference(text): + discovered.add(relative) + + unexpected = discovered - APPROVED_PRODUCTION_PORT_REFERENCES + self.assertEqual( + unexpected, + set(), + f"unreviewed production lifecycle-port references: {sorted(unexpected)}", + ) + + def test_browser_session_dependencies_are_allowlisted(self) -> None: + discovered = set() + workspace_text = ROOT_CARGO.read_text(encoding="utf-8") + for path in _production_package_manifests(ROOT): + if path == BROWSER_SESSION_CARGO: + continue + text = path.read_text(encoding="utf-8") + if _manifest_links_browser_session(text, workspace_text): + discovered.add(path.relative_to(ROOT).as_posix()) + + unexpected = discovered - APPROVED_BROWSER_SESSION_DEPENDENCIES + self.assertEqual( + unexpected, + set(), + f"unreviewed production Browser Session dependencies: {sorted(unexpected)}", + ) + + def test_allowlists_do_not_preapprove_absent_production_surfaces(self) -> None: + discovered_port_references = set() + for path in _workspace_production_sources(ROOT): + relative = path.relative_to(ROOT).as_posix() + if relative.startswith(BROWSER_SESSION_SOURCE_ROOT): + continue + if _has_port_reference(path.read_text(encoding="utf-8")): + discovered_port_references.add(relative) + + discovered_dependencies = set() + workspace_text = ROOT_CARGO.read_text(encoding="utf-8") + for path in _production_package_manifests(ROOT): + if path == BROWSER_SESSION_CARGO: + continue + if _manifest_links_browser_session(path.read_text(encoding="utf-8"), workspace_text): + discovered_dependencies.add(path.relative_to(ROOT).as_posix()) + + self.assertEqual( + APPROVED_PRODUCTION_PORT_REFERENCES, + discovered_port_references, + "adapter source allowlist must describe current production references, not reserve future paths", + ) + self.assertEqual( + APPROVED_BROWSER_SESSION_DEPENDENCIES, + discovered_dependencies, + "dependency allowlist must describe current production links, not reserve future crates", + ) + + def test_product_sources_do_not_bind_a_caller_selected_lifecycle_port(self) -> None: + callers = set() + for path in _workspace_production_sources(ROOT): + if path == ROOT / "crates/originweave-browser-session/src/browser_session.rs": + continue + text = path.read_text(encoding="utf-8") + if _has_lifecycle_binding(text): + callers.add(path.relative_to(ROOT).as_posix()) + + self.assertEqual( + callers, + set(), + "product composition must gain an explicit reviewed owner before binding a lifecycle port", + ) + + def test_scanners_cover_qualified_alias_and_ufcs_spellings(self) -> None: + port_spellings = ( + "impl originweave_browser_session::DisposableContextPort for CandidatePort {}", + ( + "use originweave_browser_session::DisposableContextPort as LifecyclePort;\n" + "impl LifecyclePort for CandidatePort {}" + ), + ) + for source in port_spellings: + self.assertTrue( + _has_port_reference(source), + f"production port spelling escaped review scanner: {source!r}", + ) + + binding_spellings = ( + "session.bind_lifecycle_port(port);", + "BrowserSession::bind_lifecycle_port(session, port);", + "session.bind_lifecycle_port (port);", + ) + for source in binding_spellings: + self.assertTrue( + _has_lifecycle_binding(source), + f"production lifecycle binding escaped review scanner: {source!r}", + ) + + def test_cross_file_alias_cannot_escape_dependency_review_surface(self) -> None: + alias_only_source = "impl LifecyclePort for CandidatePort {}" + dependency_manifests = ( + "[dependencies]\n" + 'originweave-browser-session = { path = "../originweave-browser-session" }\n', + "[dependencies]\n" + 'browser = { package = "originweave-browser-session", path = "../originweave-browser-session" }\n', + "[dependencies.originweave-browser-session]\n" + 'path = "../originweave-browser-session"\n', + ) + + self.assertFalse(_has_port_reference(alias_only_source)) + for manifest in dependency_manifests: + self.assertTrue( + _has_browser_session_dependency(manifest), + f"Browser Session dependency spelling escaped review scanner: {manifest!r}", + ) + + def test_workspace_dependency_alias_cannot_escape_dependency_review_surface(self) -> None: + workspace_manifest = ( + "[workspace.dependencies]\n" + 'browser_session = { package = "originweave-browser-session", path = "crates/originweave-browser-session" }\n' + ) + member_manifests = ( + "[dependencies]\n" + "browser_session = { workspace = true }\n", + "[target.'cfg(unix)'.dependencies]\n" + "browser_session = { workspace = true }\n", + ) + + for member_manifest in member_manifests: + self.assertTrue( + _manifest_links_browser_session(member_manifest, workspace_manifest), + "workspace dependency aliases must remain an explicit Browser Session TCB review surface", + ) + + def test_workspace_member_outside_crates_glob_cannot_escape_review_surface(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["plugins/browser-adapter"]\n', + encoding="utf-8", + ) + member = root / "plugins/browser-adapter" + member.mkdir(parents=True) + member_manifest = member / "Cargo.toml" + member_manifest.write_text( + '[package]\nname = "browser-adapter"\nversion = "0.1.0"\n', + encoding="utf-8", + ) + source = member / "src/lib.rs" + source.parent.mkdir() + source.write_text( + "pub fn browser_adapter_surface() {}\n", + encoding="utf-8", + ) + + self.assertIn(member_manifest, _production_package_manifests(root)) + self.assertIn(source, _workspace_production_sources(root)) + + def test_workspace_root_package_cannot_escape_review_surface(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + root_manifest = root / "Cargo.toml" + root_manifest.write_text( + '[package]\nname = "root-browser-adapter"\nversion = "0.1.0"\nedition = "2024"\n' + '[workspace]\nmembers = []\n', + encoding="utf-8", + ) + source = root / "src/lib.rs" + source.parent.mkdir() + source.write_text( + "pub fn root_browser_adapter_surface() {}\n", + encoding="utf-8", + ) + + self.assertIn(root_manifest, _production_package_manifests(root)) + self.assertIn(source, _workspace_production_sources(root)) + + def test_workspace_member_outside_repository_root_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + sandbox = pathlib.Path(directory) + root = sandbox / "workspace" + external = sandbox / "external-browser-adapter" + root.mkdir() + external.mkdir() + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["../external-browser-adapter"]\n', + encoding="utf-8", + ) + (external / "Cargo.toml").write_text( + '[package]\nname = "external-browser-adapter"\nversion = "0.1.0"\n', + encoding="utf-8", + ) + + with self.assertRaisesRegex(AssertionError, "member escapes repository review root"): + _workspace_member_manifests(root) + + def test_workspace_member_globs_fail_closed_until_reviewed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["plugins/*"]\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(AssertionError, "member globs require"): + _workspace_member_manifests(root) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_browser_session_webdriver_bidi_publication_trace.py b/tests/test_browser_session_webdriver_bidi_publication_trace.py new file mode 100644 index 000000000..972d3d5c9 --- /dev/null +++ b/tests/test_browser_session_webdriver_bidi_publication_trace.py @@ -0,0 +1,59 @@ +"""Repository contract for Browser Session's single-writer WebDriver BiDi provenance.""" + +from __future__ import annotations + +import pathlib +import re +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +ADR = ROOT / "docs/adr/0114-browser-session-disposable-context-authority.md" +LIFECYCLE_TRACE = ROOT / "docs/traceability/browser-session-lifecycle-authority.md" +NAVIGATION_TRACE = ROOT / "docs/traceability/browser-session-navigation-authority.md" +CANONICAL_RECEIPT = "docs/traceability/webdriver-bidi-publication-current.md" +DATED_TR = re.compile(r"WD-webdriver-bidi-\d{8}") +VOLATILE_CURRENTNESS = re.compile( + r"(?:current|latest|previous) published WebDriver BiDi Working Draft", + re.IGNORECASE, +) + + +class BrowserSessionWebDriverBidiPublicationTraceTests(unittest.TestCase): + """Keep standards freshness with the canonical originweave-bidi owner.""" + + def test_browser_session_references_canonical_publication_receipt(self) -> None: + documents = ( + ADR.read_text(encoding="utf-8"), + LIFECYCLE_TRACE.read_text(encoding="utf-8"), + NAVIGATION_TRACE.read_text(encoding="utf-8"), + ) + + for document in documents: + self.assertIn(CANONICAL_RECEIPT, document) + self.assertIsNone(DATED_TR.search(document)) + self.assertIsNone(VOLATILE_CURRENTNESS.search(document)) + + def test_browser_session_keeps_only_domain_relevant_standard_semantics(self) -> None: + adr = ADR.read_text(encoding="utf-8") + lifecycle_trace = LIFECYCLE_TRACE.read_text(encoding="utf-8") + navigation_trace = NAVIGATION_TRACE.read_text(encoding="utf-8") + + self.assertIn("browser.UserContext", adr) + self.assertIn("browser.createUserContext", adr) + self.assertIn("browsingContext.create", adr) + self.assertIn("browser.removeUserContext", adr) + self.assertIn("command ACK alone is not destruction proof", adr) + self.assertIn( + "command acknowledgement is insufficient proof", + lifecycle_trace, + ) + self.assertIn("browsingContext.navigationStarted", navigation_trace) + self.assertIn("navigationCommitted", navigation_trace) + self.assertIn("command acknowledgement is insufficient", navigation_trace) + self.assertIn("runtime compatibility", adr.lower()) + self.assertIn("runtime compatibility", lifecycle_trace.lower()) + self.assertIn("runtime compatibility", navigation_trace.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_browser_session_workspace_member_symlink_contract.py b/tests/test_browser_session_workspace_member_symlink_contract.py new file mode 100644 index 000000000..d3685429d --- /dev/null +++ b/tests/test_browser_session_workspace_member_symlink_contract.py @@ -0,0 +1,42 @@ +import pathlib +import runpy +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +CANONICAL_CONTRACT = ROOT / "tests/test_browser_session_trusted_adapter_boundary.py" + + +def _canonical_workspace_member_manifests(root: pathlib.Path) -> list[pathlib.Path]: + """Load the single-writer workspace-member scanner from the canonical security contract.""" + namespace = runpy.run_path(str(CANONICAL_CONTRACT)) + return namespace["_workspace_member_manifests"](root) + + +class BrowserSessionWorkspaceMemberSymlinkContractTests(unittest.TestCase): + """Keep symlinked workspace members inside the repository review root.""" + + def test_symlinked_external_workspace_member_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + sandbox = pathlib.Path(directory) + root = sandbox / "workspace" + external = sandbox / "external-browser-adapter" + root.mkdir() + external.mkdir() + (external / "Cargo.toml").write_text( + '[package]\nname = "external-browser-adapter"\nversion = "0.1.0"\n', + encoding="utf-8", + ) + (root / "linked-browser-adapter").symlink_to(external, target_is_directory=True) + (root / "Cargo.toml").write_text( + '[workspace]\nmembers = ["linked-browser-adapter"]\n', + encoding="utf-8", + ) + + with self.assertRaisesRegex(AssertionError, "member escapes repository review root"): + _canonical_workspace_member_manifests(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_webdriver_bidi_docs_currentness_contract.py b/tests/test_webdriver_bidi_docs_currentness_contract.py index bbd8295f6..4667564c2 100644 --- a/tests/test_webdriver_bidi_docs_currentness_contract.py +++ b/tests/test_webdriver_bidi_docs_currentness_contract.py @@ -27,10 +27,11 @@ def test_adr_tracks_merged_adapter_lineage_and_publication_receipt(self) -> None adr, ) self.assertIn("runtime-qualified 3 September 2026", adr) - self.assertIn("latest published 9 September 2026", adr) + self.assertIn("latest published 16 September 2026", adr) + self.assertIn("14 September 2026 as the previous published version", adr) def test_publication_freshness_is_single_sourced_from_runtime_qualification_docs(self) -> None: - """Architecture and doctoring stay qualification records; the receipt owns latest-publication churn.""" + """Architecture and doctoring stay qualification records; the receipt owns publication URIs.""" architecture = (ROOT / "ARCHITECTURE.md").read_text(encoding="utf-8") doctoring = (ROOT / "docs/doctoring.md").read_text(encoding="utf-8") receipt = ( @@ -38,7 +39,9 @@ def test_publication_freshness_is_single_sourced_from_runtime_qualification_docs ).read_text(encoding="utf-8") runtime_uri = "https://www.w3.org/TR/2026/WD-webdriver-bidi-20260903/" - latest_uri = "https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/" + latest_uri = "https://www.w3.org/TR/2026/WD-webdriver-bidi-20260916/" + previous_uri = "https://www.w3.org/TR/2026/WD-webdriver-bidi-20260914/" + editors_draft_uri = "https://w3c.github.io/webdriver-bidi/" for path, text in { "ARCHITECTURE.md": architecture, @@ -48,9 +51,20 @@ def test_publication_freshness_is_single_sourced_from_runtime_qualification_docs self.assertIn(runtime_uri, text) self.assertNotIn(latest_uri, text) + self.assertIn("16 September 2026 WebDriver BiDi Working Draft", doctoring) + self.assertIn("14 September 2026 Working Draft", doctoring) + self.assertIn("runtime-qualified pin", doctoring) + self.assertIn("historical/reference publication", doctoring) + self.assertIn("docs/traceability/webdriver-bidi-publication-current.md", doctoring) + + self.assertIn("Observed: 2026-09-19", receipt) self.assertIn("Runtime-compatible pin: `2026-09-03`", receipt) - self.assertIn("Latest published Working Draft: `2026-09-09`", receipt) + self.assertIn("Latest published Working Draft: `2026-09-16`", receipt) + self.assertIn("Previous published Working Draft: `2026-09-14`", receipt) + self.assertIn("Editor's Draft: `https://w3c.github.io/webdriver-bidi/`", receipt) self.assertIn(latest_uri, receipt) + self.assertIn(previous_uri, receipt) + self.assertIn(editors_draft_uri, receipt) self.assertIn( "PR #229, which has inherited merged PR #293", receipt, diff --git a/tests/test_webdriver_bidi_presentation_adapter_contract.py b/tests/test_webdriver_bidi_presentation_adapter_contract.py index b808f66f0..fe8931854 100644 --- a/tests/test_webdriver_bidi_presentation_adapter_contract.py +++ b/tests/test_webdriver_bidi_presentation_adapter_contract.py @@ -48,13 +48,14 @@ def test_latest_published_bidi_is_tracked_without_silently_repinning_adapter(sel "RED: latest WebDriver BiDi publication is not traceable beside the qualified runtime pin", ) receipt = publication_receipt.read_text(encoding="utf-8") - self.assertIn("2026-09-09", receipt) + self.assertIn("Runtime-compatible pin: `2026-09-03`", receipt) self.assertIn( - "https://www.w3.org/TR/2026/WD-webdriver-bidi-20260909/", + "Canonical publication history: https://www.w3.org/standards/history/webdriver-bidi/", receipt, ) - self.assertIn("Runtime-compatible pin: `2026-09-03`", receipt) - self.assertIn("Latest published Working Draft: `2026-09-09`", receipt) + self.assertIn("Latest published Working Draft:", receipt) + self.assertIn("Previous published Working Draft:", receipt) + self.assertNotIn("Latest published Working Draft: `2026-09-03`", receipt) self.assertIn("PresentationSurface::Screen", text) self.assertIn("PresentationSurface::Viewport", text)