From b6b5b2746f4319454d6abf996f731291f8e77799 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:04:09 +0900 Subject: [PATCH 001/570] test(sensitive): require credential-free handle lifecycle evidence --- .../sensitive_handle_lifecycle_evidence.rs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs new file mode 100644 index 000000000..8cce6210f --- /dev/null +++ b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs @@ -0,0 +1,109 @@ +use originweave_evidence::{ + SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, + SensitiveHandleLifecycleEvidenceInput, +}; + +fn valid_input() -> SensitiveHandleLifecycleEvidenceInput { + SensitiveHandleLifecycleEvidenceInput { + request_id: "request-42".to_owned(), + decision_id: "decision-42".to_owned(), + issued_epoch_seconds: 1_720_000_000, + expires_epoch_seconds: 1_720_000_300, + maximum_uses: 2, + resolution_count: 1, + revoked_epoch_seconds: None, + } +} + +#[test] +fn records_bounded_handle_lifecycle_without_handle_or_secret_material() { + let evidence = SensitiveHandleLifecycleEvidence::try_from(valid_input()).expect("valid evidence"); + + assert_eq!(evidence.request_id(), "request-42"); + assert_eq!(evidence.decision_id(), "decision-42"); + assert_eq!(evidence.issued_epoch_seconds(), 1_720_000_000); + assert_eq!(evidence.expires_epoch_seconds(), 1_720_000_300); + assert_eq!(evidence.maximum_uses(), 2); + assert_eq!(evidence.resolution_count(), 1); + assert_eq!(evidence.revoked_epoch_seconds(), None); + assert!(!evidence.is_revoked()); + + let debug = format!("{evidence:?}"); + assert!(!debug.contains("opaque-handle-token-should-never-be-evidence")); + assert!(!debug.contains("raw-secret-should-never-be-evidence")); +} + +#[test] +fn records_revocation_time_without_storing_revocation_payloads() { + let mut input = valid_input(); + input.revoked_epoch_seconds = Some(1_720_000_120); + input.resolution_count = 2; + + let evidence = SensitiveHandleLifecycleEvidence::try_from(input).expect("valid revoked evidence"); + + assert_eq!(evidence.revoked_epoch_seconds(), Some(1_720_000_120)); + assert!(evidence.is_revoked()); + assert_eq!(evidence.resolution_count(), evidence.maximum_uses()); +} + +#[test] +fn rejects_invalid_request_or_decision_identifiers() { + for mutate in [0_u8, 1_u8] { + let mut input = valid_input(); + if mutate == 0 { + input.request_id = "bad/request".to_owned(); + } else { + input.decision_id = String::new(); + } + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(input), + Err(SensitiveEvidenceError::InvalidIdentifier) + ); + } +} + +#[test] +fn rejects_zero_or_non_increasing_handle_lifetime() { + for (issued, expires) in [ + (0, 1_720_000_300), + (1_720_000_300, 1_720_000_300), + (1_720_000_301, 1_720_000_300), + ] { + let mut input = valid_input(); + input.issued_epoch_seconds = issued; + input.expires_epoch_seconds = expires; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(input), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + } +} + +#[test] +fn rejects_zero_use_limit_or_resolution_count_above_limit() { + let mut zero_limit = valid_input(); + zero_limit.maximum_uses = 0; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(zero_limit), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + + let mut overused = valid_input(); + overused.resolution_count = overused.maximum_uses + 1; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(overused), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); +} + +#[test] +fn rejects_revocation_outside_handle_lifetime() { + for revoked in [1_719_999_999, 1_720_000_301] { + let mut input = valid_input(); + input.revoked_epoch_seconds = Some(revoked); + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(input), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + } +} From 1904c4374ec44b0973c6f3428ba961a920789357 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:05:43 +0900 Subject: [PATCH 002/570] style(sensitive): apply canonical Rust formatting --- .../tests/sensitive_handle_lifecycle_evidence.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs index 8cce6210f..e062dab3b 100644 --- a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs +++ b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs @@ -1,6 +1,5 @@ use originweave_evidence::{ - SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, - SensitiveHandleLifecycleEvidenceInput, + SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, SensitiveHandleLifecycleEvidenceInput, }; fn valid_input() -> SensitiveHandleLifecycleEvidenceInput { @@ -17,7 +16,8 @@ fn valid_input() -> SensitiveHandleLifecycleEvidenceInput { #[test] fn records_bounded_handle_lifecycle_without_handle_or_secret_material() { - let evidence = SensitiveHandleLifecycleEvidence::try_from(valid_input()).expect("valid evidence"); + let evidence = + SensitiveHandleLifecycleEvidence::try_from(valid_input()).expect("valid evidence"); assert_eq!(evidence.request_id(), "request-42"); assert_eq!(evidence.decision_id(), "decision-42"); @@ -39,7 +39,8 @@ fn records_revocation_time_without_storing_revocation_payloads() { input.revoked_epoch_seconds = Some(1_720_000_120); input.resolution_count = 2; - let evidence = SensitiveHandleLifecycleEvidence::try_from(input).expect("valid revoked evidence"); + let evidence = + SensitiveHandleLifecycleEvidence::try_from(input).expect("valid revoked evidence"); assert_eq!(evidence.revoked_epoch_seconds(), Some(1_720_000_120)); assert!(evidence.is_revoked()); From 9170cd39a5acf5b6d2693d75d4e406c305cc045f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:07:56 +0900 Subject: [PATCH 003/570] feat(sensitive): add credential-free handle lifecycle evidence --- .../src/sensitive_handle_lifecycle.rs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 crates/originweave-evidence/src/sensitive_handle_lifecycle.rs diff --git a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs new file mode 100644 index 000000000..36b1479fb --- /dev/null +++ b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs @@ -0,0 +1,134 @@ +//! Credential-free lifecycle evidence for opaque sensitive-value handles. +//! +//! A trusted broker can use this value object to record when a handle was +//! issued, when it expires, how many uses it permits, how many resolutions were +//! observed, and when it was revoked. The evidence intentionally has no field +//! for the opaque handle token or the protected value behind that token. + +use crate::sensitive_access::{SensitiveEvidenceError, MAX_SENSITIVE_IDENTIFIER_BYTES}; + +/// Unvalidated metadata describing one opaque sensitive-value handle lifecycle. +/// +/// This input records correlation identifiers and bounded lifecycle counters +/// only. It cannot carry the opaque handle token or a protected value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SensitiveHandleLifecycleEvidenceInput { + /// Correlation identifier for the sensitive-data access request. + pub request_id: String, + /// Identifier for the policy decision that authorized or denied the handle. + pub decision_id: String, + /// Trusted Unix epoch second when the handle was issued. + pub issued_epoch_seconds: u64, + /// Trusted Unix epoch second after which the handle is no longer valid. + pub expires_epoch_seconds: u64, + /// Maximum number of broker resolutions authorized for the handle. + pub maximum_uses: u32, + /// Number of broker resolutions already observed for the handle. + pub resolution_count: u32, + /// Trusted Unix epoch second when the handle was revoked, when applicable. + pub revoked_epoch_seconds: Option, +} + +/// Immutable credential-free evidence about one opaque handle lifecycle. +/// +/// The value deliberately excludes both the opaque handle token and the secret +/// or protected value that the broker can resolve from it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SensitiveHandleLifecycleEvidence { + request_id: String, + decision_id: String, + issued_epoch_seconds: u64, + expires_epoch_seconds: u64, + maximum_uses: u32, + resolution_count: u32, + revoked_epoch_seconds: Option, +} + +impl TryFrom for SensitiveHandleLifecycleEvidence { + type Error = SensitiveEvidenceError; + + fn try_from(input: SensitiveHandleLifecycleEvidenceInput) -> Result { + if !valid_identifier(&input.request_id) || !valid_identifier(&input.decision_id) { + return Err(SensitiveEvidenceError::InvalidIdentifier); + } + if input.issued_epoch_seconds == 0 + || input.expires_epoch_seconds <= input.issued_epoch_seconds + || input.maximum_uses == 0 + || input.resolution_count > input.maximum_uses + || input.revoked_epoch_seconds.is_some_and(|revoked| { + revoked < input.issued_epoch_seconds || revoked > input.expires_epoch_seconds + }) + { + return Err(SensitiveEvidenceError::InvalidLifecycle); + } + + Ok(Self { + request_id: input.request_id, + decision_id: input.decision_id, + issued_epoch_seconds: input.issued_epoch_seconds, + expires_epoch_seconds: input.expires_epoch_seconds, + maximum_uses: input.maximum_uses, + resolution_count: input.resolution_count, + revoked_epoch_seconds: input.revoked_epoch_seconds, + }) + } +} + +impl SensitiveHandleLifecycleEvidence { + /// Return the originating sensitive-data access request identifier. + #[must_use] + pub fn request_id(&self) -> &str { + &self.request_id + } + + /// Return the policy decision identifier associated with the handle. + #[must_use] + pub fn decision_id(&self) -> &str { + &self.decision_id + } + + /// Return the trusted handle issuance time as a Unix epoch second. + #[must_use] + pub const fn issued_epoch_seconds(&self) -> u64 { + self.issued_epoch_seconds + } + + /// Return the trusted handle expiry time as a Unix epoch second. + #[must_use] + pub const fn expires_epoch_seconds(&self) -> u64 { + self.expires_epoch_seconds + } + + /// Return the maximum number of broker resolutions authorized for the handle. + #[must_use] + pub const fn maximum_uses(&self) -> u32 { + self.maximum_uses + } + + /// Return the number of broker resolutions already observed for the handle. + #[must_use] + pub const fn resolution_count(&self) -> u32 { + self.resolution_count + } + + /// Return the trusted revocation time when the handle has been revoked. + #[must_use] + pub const fn revoked_epoch_seconds(&self) -> Option { + self.revoked_epoch_seconds + } + + /// Return whether trusted evidence records that this handle was revoked. + #[must_use] + pub const fn is_revoked(&self) -> bool { + self.revoked_epoch_seconds.is_some() + } +} + +fn valid_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_SENSITIVE_IDENTIFIER_BYTES + && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) +} From db61f92d62c2a1670cc497d46c17b6c73c2edd0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:08:43 +0900 Subject: [PATCH 004/570] feat(sensitive): export handle lifecycle evidence --- crates/originweave-evidence/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index ad183e9eb..bc2b286a5 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -8,12 +8,16 @@ #![deny(missing_docs)] mod sensitive_access; +mod sensitive_handle_lifecycle; pub use sensitive_access::{ MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, SensitiveAccessOutcome, SensitiveEvidenceError, }; +pub use sensitive_handle_lifecycle::{ + SensitiveHandleLifecycleEvidence, SensitiveHandleLifecycleEvidenceInput, +}; use std::collections::BTreeMap; From b19576fc0561d4156dca08a730ae0c2023d879a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:09:46 +0900 Subject: [PATCH 005/570] test(sensitive): cover every handle identifier validation branch --- .../sensitive_handle_lifecycle_evidence.rs | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs index e062dab3b..a9e1b933e 100644 --- a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs +++ b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs @@ -1,5 +1,6 @@ use originweave_evidence::{ - SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, SensitiveHandleLifecycleEvidenceInput, + MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, + SensitiveHandleLifecycleEvidenceInput, }; fn valid_input() -> SensitiveHandleLifecycleEvidenceInput { @@ -49,18 +50,27 @@ fn records_revocation_time_without_storing_revocation_payloads() { #[test] fn rejects_invalid_request_or_decision_identifiers() { - for mutate in [0_u8, 1_u8] { + let invalid_request_ids = [ + String::new(), + "-".repeat(3), + "bad/request".to_owned(), + "a".repeat(MAX_SENSITIVE_IDENTIFIER_BYTES + 1), + ]; + for request_id in invalid_request_ids { let mut input = valid_input(); - if mutate == 0 { - input.request_id = "bad/request".to_owned(); - } else { - input.decision_id = String::new(); - } + input.request_id = request_id; assert_eq!( SensitiveHandleLifecycleEvidence::try_from(input), Err(SensitiveEvidenceError::InvalidIdentifier) ); } + + let mut invalid_decision = valid_input(); + invalid_decision.decision_id = String::new(); + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(invalid_decision), + Err(SensitiveEvidenceError::InvalidIdentifier) + ); } #[test] From 79365742dca923f8010c12cfa149cd7408c4805a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:11:28 +0900 Subject: [PATCH 006/570] style(sensitive): apply rustfmt import ordering --- crates/originweave-evidence/src/sensitive_handle_lifecycle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs index 36b1479fb..57d4f959b 100644 --- a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs +++ b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs @@ -5,7 +5,7 @@ //! observed, and when it was revoked. The evidence intentionally has no field //! for the opaque handle token or the protected value behind that token. -use crate::sensitive_access::{SensitiveEvidenceError, MAX_SENSITIVE_IDENTIFIER_BYTES}; +use crate::sensitive_access::{MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveEvidenceError}; /// Unvalidated metadata describing one opaque sensitive-value handle lifecycle. /// From ed8fe5ff8eb00bcc1c11445d6f16a3c899ed8060 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:14:44 +0900 Subject: [PATCH 007/570] test(sensitive): satisfy no-panic lint contract --- .../tests/sensitive_handle_lifecycle_evidence.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs index a9e1b933e..2af810f81 100644 --- a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs +++ b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs @@ -15,10 +15,16 @@ fn valid_input() -> SensitiveHandleLifecycleEvidenceInput { } } +fn valid_evidence(input: SensitiveHandleLifecycleEvidenceInput) -> SensitiveHandleLifecycleEvidence { + match SensitiveHandleLifecycleEvidence::try_from(input) { + Ok(evidence) => evidence, + Err(error) => panic!("expected valid lifecycle evidence, got {error:?}"), + } +} + #[test] fn records_bounded_handle_lifecycle_without_handle_or_secret_material() { - let evidence = - SensitiveHandleLifecycleEvidence::try_from(valid_input()).expect("valid evidence"); + let evidence = valid_evidence(valid_input()); assert_eq!(evidence.request_id(), "request-42"); assert_eq!(evidence.decision_id(), "decision-42"); @@ -40,8 +46,7 @@ fn records_revocation_time_without_storing_revocation_payloads() { input.revoked_epoch_seconds = Some(1_720_000_120); input.resolution_count = 2; - let evidence = - SensitiveHandleLifecycleEvidence::try_from(input).expect("valid revoked evidence"); + let evidence = valid_evidence(input); assert_eq!(evidence.revoked_epoch_seconds(), Some(1_720_000_120)); assert!(evidence.is_revoked()); From dc1ebd2ce8050568495d448695dbdb8006da8c3d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:15:12 +0900 Subject: [PATCH 008/570] test(sensitive): return Result on valid evidence paths --- .../sensitive_handle_lifecycle_evidence.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs index 2af810f81..c6d294ba6 100644 --- a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs +++ b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs @@ -15,16 +15,10 @@ fn valid_input() -> SensitiveHandleLifecycleEvidenceInput { } } -fn valid_evidence(input: SensitiveHandleLifecycleEvidenceInput) -> SensitiveHandleLifecycleEvidence { - match SensitiveHandleLifecycleEvidence::try_from(input) { - Ok(evidence) => evidence, - Err(error) => panic!("expected valid lifecycle evidence, got {error:?}"), - } -} - #[test] -fn records_bounded_handle_lifecycle_without_handle_or_secret_material() { - let evidence = valid_evidence(valid_input()); +fn records_bounded_handle_lifecycle_without_handle_or_secret_material( +) -> Result<(), SensitiveEvidenceError> { + let evidence = SensitiveHandleLifecycleEvidence::try_from(valid_input())?; assert_eq!(evidence.request_id(), "request-42"); assert_eq!(evidence.decision_id(), "decision-42"); @@ -38,19 +32,22 @@ fn records_bounded_handle_lifecycle_without_handle_or_secret_material() { let debug = format!("{evidence:?}"); assert!(!debug.contains("opaque-handle-token-should-never-be-evidence")); assert!(!debug.contains("raw-secret-should-never-be-evidence")); + Ok(()) } #[test] -fn records_revocation_time_without_storing_revocation_payloads() { +fn records_revocation_time_without_storing_revocation_payloads( +) -> Result<(), SensitiveEvidenceError> { let mut input = valid_input(); input.revoked_epoch_seconds = Some(1_720_000_120); input.resolution_count = 2; - let evidence = valid_evidence(input); + let evidence = SensitiveHandleLifecycleEvidence::try_from(input)?; assert_eq!(evidence.revoked_epoch_seconds(), Some(1_720_000_120)); assert!(evidence.is_revoked()); assert_eq!(evidence.resolution_count(), evidence.maximum_uses()); + Ok(()) } #[test] From e155bbdb13470bb70481bfb1402820fd2c2836d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:17:16 +0900 Subject: [PATCH 009/570] style(sensitive): apply exact rustfmt function wrapping --- .../tests/sensitive_handle_lifecycle_evidence.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs index c6d294ba6..0aef58535 100644 --- a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs +++ b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs @@ -16,8 +16,8 @@ fn valid_input() -> SensitiveHandleLifecycleEvidenceInput { } #[test] -fn records_bounded_handle_lifecycle_without_handle_or_secret_material( -) -> Result<(), SensitiveEvidenceError> { +fn records_bounded_handle_lifecycle_without_handle_or_secret_material() +-> Result<(), SensitiveEvidenceError> { let evidence = SensitiveHandleLifecycleEvidence::try_from(valid_input())?; assert_eq!(evidence.request_id(), "request-42"); @@ -36,8 +36,8 @@ fn records_bounded_handle_lifecycle_without_handle_or_secret_material( } #[test] -fn records_revocation_time_without_storing_revocation_payloads( -) -> Result<(), SensitiveEvidenceError> { +fn records_revocation_time_without_storing_revocation_payloads() +-> Result<(), SensitiveEvidenceError> { let mut input = valid_input(); input.revoked_epoch_seconds = Some(1_720_000_120); input.resolution_count = 2; From ca53a69893eec921d51679eb14b0a41b39ff91b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:21:24 +0900 Subject: [PATCH 010/570] refactor(sensitive): share identifier validation --- crates/originweave-evidence/src/sensitive_access.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-evidence/src/sensitive_access.rs b/crates/originweave-evidence/src/sensitive_access.rs index 9123119f7..625b89804 100644 --- a/crates/originweave-evidence/src/sensitive_access.rs +++ b/crates/originweave-evidence/src/sensitive_access.rs @@ -297,7 +297,7 @@ fn validate_fields(field_ids: &[String]) -> Result<(), SensitiveEvidenceError> { Ok(()) } -fn valid_identifier(value: &str) -> bool { +pub(crate) fn valid_identifier(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_SENSITIVE_IDENTIFIER_BYTES && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) From 0f07fea031090c72a448fd9501b49d4dd7568419 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 14:21:45 +0900 Subject: [PATCH 011/570] refactor(sensitive): reuse shared evidence identifier validator --- .../src/sensitive_handle_lifecycle.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs index 57d4f959b..512406015 100644 --- a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs +++ b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs @@ -5,7 +5,7 @@ //! observed, and when it was revoked. The evidence intentionally has no field //! for the opaque handle token or the protected value behind that token. -use crate::sensitive_access::{MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveEvidenceError}; +use crate::sensitive_access::{SensitiveEvidenceError, valid_identifier}; /// Unvalidated metadata describing one opaque sensitive-value handle lifecycle. /// @@ -123,12 +123,3 @@ impl SensitiveHandleLifecycleEvidence { self.revoked_epoch_seconds.is_some() } } - -fn valid_identifier(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_SENSITIVE_IDENTIFIER_BYTES - && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) -} From d21f63185d648ac1aa5982377d6d19190899717f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 18:45:59 +0900 Subject: [PATCH 012/570] test(destination): require bounded resolution freshness authority --- .../tests/resolution_freshness.rs | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 crates/originweave-destination/tests/resolution_freshness.rs diff --git a/crates/originweave-destination/tests/resolution_freshness.rs b/crates/originweave-destination/tests/resolution_freshness.rs new file mode 100644 index 000000000..f0053859f --- /dev/null +++ b/crates/originweave-destination/tests/resolution_freshness.rs @@ -0,0 +1,145 @@ +#![allow(clippy::expect_used)] + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use originweave_core::Origin; +use originweave_destination::{ + DestinationError, DestinationPolicy, FreshResolutionSnapshot, MAX_RESOLUTION_VALIDITY, +}; + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("test origin must parse") +} + +fn ipv4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) +} + +#[test] +fn fresh_resolution_authority_is_half_open_and_bound_to_pinned_addresses() { + let approved_at = Duration::from_secs(100); + let validity = Duration::from_secs(5); + let approved = ipv4(8, 8, 8, 8); + let snapshot = FreshResolutionSnapshot::approve( + origin("https://example.com"), + [approved], + &DestinationPolicy::public_web(), + approved_at, + validity, + ) + .expect("bounded fresh resolution"); + + assert_eq!(snapshot.approved_at(), approved_at); + assert_eq!(snapshot.validity(), validity); + assert_eq!(snapshot.valid_until(), Duration::from_secs(105)); + + let evidence = snapshot + .authorize_connection(approved, approved_at) + .expect("authority begins at approval time"); + assert_eq!(evidence.resolution_approved_at(), approved_at); + assert_eq!(evidence.resolution_valid_until(), Duration::from_secs(105)); + assert_eq!(evidence.authorized_at(), approved_at); + + snapshot + .authorize_connection(approved, Duration::from_secs(104)) + .expect("authority remains valid before the exclusive deadline"); + + assert_eq!( + snapshot.authorize_connection(approved, Duration::from_secs(99)), + Err(DestinationError::ResolutionUseBeforeApproval { + approved_at, + current_time: Duration::from_secs(99), + }) + ); + assert_eq!( + snapshot.authorize_connection(approved, Duration::from_secs(105)), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: Duration::from_secs(105), + current_time: Duration::from_secs(105), + }) + ); + assert_eq!( + snapshot.authorize_connection(ipv4(9, 9, 9, 9), approved_at), + Err(DestinationError::UnapprovedConnectionAddress { + address: ipv4(9, 9, 9, 9), + }) + ); +} + +#[test] +fn fresh_resolution_rejects_invalid_or_overflowing_validity() { + let target = origin("https://example.com"); + let address = ipv4(8, 8, 8, 8); + let policy = DestinationPolicy::public_web(); + + for validity in [Duration::ZERO, MAX_RESOLUTION_VALIDITY + Duration::from_nanos(1)] { + assert_eq!( + FreshResolutionSnapshot::approve( + target.clone(), + [address], + &policy, + Duration::from_secs(1), + validity, + ), + Err(DestinationError::InvalidResolutionValidity { + validity, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }) + ); + } + + assert_eq!( + FreshResolutionSnapshot::approve( + target, + [address], + &policy, + Duration::MAX, + Duration::from_nanos(1), + ), + Err(DestinationError::ResolutionValidityOverflow { + approved_at: Duration::MAX, + validity: Duration::from_nanos(1), + }) + ); +} + +#[test] +fn fresh_revalidation_preserves_the_budget_and_resets_approval_time() { + let first = ipv4(8, 8, 8, 8); + let second = ipv4(1, 1, 1, 1); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin("https://example.com"), + [first, second], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial fresh resolution"); + + let refreshed = snapshot + .revalidate([second], &policy, Duration::from_secs(13)) + .expect("a fresh non-expanding answer renews the bounded window"); + assert_eq!(refreshed.addresses(), &std::collections::BTreeSet::from([second])); + assert_eq!(refreshed.approved_at(), Duration::from_secs(13)); + assert_eq!(refreshed.validity(), Duration::from_secs(4)); + assert_eq!(refreshed.valid_until(), Duration::from_secs(17)); + refreshed + .authorize_connection(second, Duration::from_secs(16)) + .expect("refreshed authority is usable before its new deadline"); + + assert_eq!( + snapshot.revalidate([second], &policy, Duration::from_secs(9)), + Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: Duration::from_secs(10), + current_time: Duration::from_secs(9), + }) + ); + assert_eq!( + snapshot.revalidate([first, ipv4(9, 9, 9, 9)], &policy, Duration::from_secs(11)), + Err(DestinationError::ResolutionSetExpanded { + address: ipv4(9, 9, 9, 9), + }) + ); +} From f8cb43492fd48eb8634406a3c3a2065930fbef55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 18:48:23 +0900 Subject: [PATCH 013/570] test(destination): format freshness contract before RED --- .../tests/resolution_freshness.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/originweave-destination/tests/resolution_freshness.rs b/crates/originweave-destination/tests/resolution_freshness.rs index f0053859f..027ac42b5 100644 --- a/crates/originweave-destination/tests/resolution_freshness.rs +++ b/crates/originweave-destination/tests/resolution_freshness.rs @@ -73,7 +73,10 @@ fn fresh_resolution_rejects_invalid_or_overflowing_validity() { let address = ipv4(8, 8, 8, 8); let policy = DestinationPolicy::public_web(); - for validity in [Duration::ZERO, MAX_RESOLUTION_VALIDITY + Duration::from_nanos(1)] { + for validity in [ + Duration::ZERO, + MAX_RESOLUTION_VALIDITY + Duration::from_nanos(1), + ] { assert_eq!( FreshResolutionSnapshot::approve( target.clone(), @@ -121,7 +124,10 @@ fn fresh_revalidation_preserves_the_budget_and_resets_approval_time() { let refreshed = snapshot .revalidate([second], &policy, Duration::from_secs(13)) .expect("a fresh non-expanding answer renews the bounded window"); - assert_eq!(refreshed.addresses(), &std::collections::BTreeSet::from([second])); + assert_eq!( + refreshed.addresses(), + &std::collections::BTreeSet::from([second]) + ); assert_eq!(refreshed.approved_at(), Duration::from_secs(13)); assert_eq!(refreshed.validity(), Duration::from_secs(4)); assert_eq!(refreshed.valid_until(), Duration::from_secs(17)); From 734da192cdfec465c50583b14ef59d6f1fd0789f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 18:50:50 +0900 Subject: [PATCH 014/570] feat(destination): add bounded resolution freshness authority --- .../originweave-destination/src/resolution.rs | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) diff --git a/crates/originweave-destination/src/resolution.rs b/crates/originweave-destination/src/resolution.rs index f55d1722b..45620e6cd 100644 --- a/crates/originweave-destination/src/resolution.rs +++ b/crates/originweave-destination/src/resolution.rs @@ -1,6 +1,7 @@ use std::collections::BTreeSet; use std::fmt; use std::net::IpAddr; +use std::time::Duration; use originweave_core::Origin; @@ -9,6 +10,13 @@ use crate::{AddressClass, ClassifiedAddress, classify_address}; /// The largest resolver answer accepted by one resolution snapshot. pub const MAX_RESOLUTION_ADDRESS_COUNT: usize = 256; +/// The largest freshness interval accepted for one resolution approval. +/// +/// This is an OriginWeave product safety budget, not a DNS protocol validity +/// rule. Callers may choose any smaller non-zero interval appropriate to their +/// resolver and network adapter. +pub const MAX_RESOLUTION_VALIDITY: Duration = Duration::from_secs(30); + /// A fail-closed allow-list of destination address classes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DestinationPolicy { @@ -103,6 +111,34 @@ pub enum DestinationError { /// The newly introduced canonical address. address: IpAddr, }, + /// A freshness interval was zero or exceeded [`MAX_RESOLUTION_VALIDITY`]. + InvalidResolutionValidity { + /// The rejected freshness interval. + validity: Duration, + /// The largest accepted freshness interval. + maximum_validity: Duration, + }, + /// Adding the freshness interval to the approval time overflowed. + ResolutionValidityOverflow { + /// The trusted monotonic time at which the answer was approved. + approved_at: Duration, + /// The requested freshness interval. + validity: Duration, + }, + /// A caller supplied a monotonic time earlier than the recorded approval. + ResolutionUseBeforeApproval { + /// The recorded approval time. + approved_at: Duration, + /// The caller-supplied current time. + current_time: Duration, + }, + /// A bounded resolution approval reached its exclusive validity deadline. + ResolutionApprovalExpired { + /// The exclusive upper bound of the approval interval. + valid_until: Duration, + /// The caller-supplied current time. + current_time: Duration, + }, } impl fmt::Display for DestinationError { @@ -142,6 +178,34 @@ impl fmt::Display for DestinationError { formatter, "refreshed DNS answer introduced unapproved address {address}", ), + Self::InvalidResolutionValidity { + validity, + maximum_validity, + } => write!( + formatter, + "resolution validity {validity:?} is outside 1ns..={maximum_validity:?}", + ), + Self::ResolutionValidityOverflow { + approved_at, + validity, + } => write!( + formatter, + "resolution validity {validity:?} overflows approval time {approved_at:?}", + ), + Self::ResolutionUseBeforeApproval { + approved_at, + current_time, + } => write!( + formatter, + "resolution use time {current_time:?} precedes approval time {approved_at:?}", + ), + Self::ResolutionApprovalExpired { + valid_until, + current_time, + } => write!( + formatter, + "resolution approval expired at {valid_until:?}; current time is {current_time:?}", + ), } } } @@ -254,6 +318,143 @@ impl ResolutionSnapshot { } } +/// A resolution snapshot bound to one explicit trusted monotonic validity window. +/// +/// The time values are opaque durations from one caller-owned monotonic clock +/// domain. This type never reads a wall clock itself. Constructing a new fresh +/// snapshot always reruns the same destination validation used by +/// [`ResolutionSnapshot`], so callers cannot renew authority without presenting +/// another policy-valid answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreshResolutionSnapshot { + snapshot: ResolutionSnapshot, + approved_at: Duration, + validity: Duration, + valid_until: Duration, +} + +impl FreshResolutionSnapshot { + /// Validate addresses and bind the resulting snapshot to a bounded lifetime. + pub fn approve( + origin: Origin, + addresses: impl IntoIterator, + policy: &DestinationPolicy, + approved_at: Duration, + validity: Duration, + ) -> Result { + let snapshot = ResolutionSnapshot::approve(origin, addresses, policy)?; + Self::from_snapshot(snapshot, approved_at, validity) + } + + fn from_snapshot( + snapshot: ResolutionSnapshot, + approved_at: Duration, + validity: Duration, + ) -> Result { + if validity.is_zero() || validity > MAX_RESOLUTION_VALIDITY { + return Err(DestinationError::InvalidResolutionValidity { + validity, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }); + } + let Some(valid_until) = approved_at.checked_add(validity) else { + return Err(DestinationError::ResolutionValidityOverflow { + approved_at, + validity, + }); + }; + Ok(Self { + snapshot, + approved_at, + validity, + valid_until, + }) + } + + /// Return the logical origin whose DNS answer was approved. + #[must_use] + pub const fn origin(&self) -> &Origin { + self.snapshot.origin() + } + + /// Return the canonical addresses pinned for this fresh snapshot. + #[must_use] + pub const fn addresses(&self) -> &BTreeSet { + self.snapshot.addresses() + } + + /// Return the trusted monotonic approval time. + #[must_use] + pub const fn approved_at(&self) -> Duration { + self.approved_at + } + + /// Return the configured non-zero validity budget. + #[must_use] + pub const fn validity(&self) -> Duration { + self.validity + } + + /// Return the exclusive upper bound of the approval interval. + #[must_use] + pub const fn valid_until(&self) -> Duration { + self.valid_until + } + + /// Authorize one pinned address only while the freshness window is valid. + pub fn authorize_connection( + &self, + address: IpAddr, + current_time: Duration, + ) -> Result { + self.validate_current_time(current_time)?; + let connection = self.snapshot.authorize_connection(address)?; + Ok(FreshConnectionEvidence { + connection, + resolution_approved_at: self.approved_at, + resolution_valid_until: self.valid_until, + authorized_at: current_time, + }) + } + + /// Revalidate a fresh answer and renew the same bounded validity budget. + /// + /// `revalidated_at` must come from the same monotonic clock domain and may + /// not precede this snapshot's approval time. Expansion of the pinned set + /// remains fail-closed under [`ResolutionSnapshot::revalidate`]. + pub fn revalidate( + &self, + addresses: impl IntoIterator, + policy: &DestinationPolicy, + revalidated_at: Duration, + ) -> Result { + if revalidated_at < self.approved_at { + return Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: self.approved_at, + current_time: revalidated_at, + }); + } + let snapshot = self.snapshot.revalidate(addresses, policy)?; + Self::from_snapshot(snapshot, revalidated_at, self.validity) + } + + fn validate_current_time(&self, current_time: Duration) -> Result<(), DestinationError> { + if current_time < self.approved_at { + return Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: self.approved_at, + current_time, + }); + } + if current_time >= self.valid_until { + return Err(DestinationError::ResolutionApprovalExpired { + valid_until: self.valid_until, + current_time, + }); + } + Ok(()) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OriginHostConstraint { Domain, @@ -344,3 +545,38 @@ impl ConnectionEvidence { self.address_class } } + +/// Credential-free evidence that a pinned connection address was used while fresh. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreshConnectionEvidence { + connection: ConnectionEvidence, + resolution_approved_at: Duration, + resolution_valid_until: Duration, + authorized_at: Duration, +} + +impl FreshConnectionEvidence { + /// Return the underlying canonical destination/connection evidence. + #[must_use] + pub const fn connection_evidence(&self) -> &ConnectionEvidence { + &self.connection + } + + /// Return the trusted monotonic time at which the answer was approved. + #[must_use] + pub const fn resolution_approved_at(&self) -> Duration { + self.resolution_approved_at + } + + /// Return the exclusive upper bound of the resolution approval interval. + #[must_use] + pub const fn resolution_valid_until(&self) -> Duration { + self.resolution_valid_until + } + + /// Return the trusted monotonic time used for this authorization decision. + #[must_use] + pub const fn authorized_at(&self) -> Duration { + self.authorized_at + } +} From cbe5f87ce9107f697c2031fd63ad57093473cb1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 18:51:06 +0900 Subject: [PATCH 015/570] feat(destination): export freshness authority contract --- crates/originweave-destination/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/originweave-destination/src/lib.rs b/crates/originweave-destination/src/lib.rs index 5fdf2d363..0b27014ba 100644 --- a/crates/originweave-destination/src/lib.rs +++ b/crates/originweave-destination/src/lib.rs @@ -24,6 +24,7 @@ pub use redirect::{ RedirectTargetDigestError, }; pub use resolution::{ - ConnectionEvidence, DestinationError, DestinationPolicy, MAX_RESOLUTION_ADDRESS_COUNT, + ConnectionEvidence, DestinationError, DestinationPolicy, FreshConnectionEvidence, + FreshResolutionSnapshot, MAX_RESOLUTION_ADDRESS_COUNT, MAX_RESOLUTION_VALIDITY, ResolutionSnapshot, -}; +}; \ No newline at end of file From 34873d1cfc083493f626d8aef2c16ac618069d9a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 18:51:35 +0900 Subject: [PATCH 016/570] test(destination): cover freshness evidence and errors --- .../tests/resolution_freshness.rs | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/originweave-destination/tests/resolution_freshness.rs b/crates/originweave-destination/tests/resolution_freshness.rs index 027ac42b5..d47fd8821 100644 --- a/crates/originweave-destination/tests/resolution_freshness.rs +++ b/crates/originweave-destination/tests/resolution_freshness.rs @@ -5,7 +5,8 @@ use std::time::Duration; use originweave_core::Origin; use originweave_destination::{ - DestinationError, DestinationPolicy, FreshResolutionSnapshot, MAX_RESOLUTION_VALIDITY, + AddressClass, DestinationError, DestinationPolicy, FreshResolutionSnapshot, + MAX_RESOLUTION_VALIDITY, }; fn origin(value: &str) -> Origin { @@ -20,9 +21,10 @@ fn ipv4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { fn fresh_resolution_authority_is_half_open_and_bound_to_pinned_addresses() { let approved_at = Duration::from_secs(100); let validity = Duration::from_secs(5); + let target = origin("https://example.com"); let approved = ipv4(8, 8, 8, 8); let snapshot = FreshResolutionSnapshot::approve( - origin("https://example.com"), + target.clone(), [approved], &DestinationPolicy::public_web(), approved_at, @@ -30,6 +32,7 @@ fn fresh_resolution_authority_is_half_open_and_bound_to_pinned_addresses() { ) .expect("bounded fresh resolution"); + assert_eq!(snapshot.origin(), &target); assert_eq!(snapshot.approved_at(), approved_at); assert_eq!(snapshot.validity(), validity); assert_eq!(snapshot.valid_until(), Duration::from_secs(105)); @@ -37,6 +40,11 @@ fn fresh_resolution_authority_is_half_open_and_bound_to_pinned_addresses() { let evidence = snapshot .authorize_connection(approved, approved_at) .expect("authority begins at approval time"); + let connection = evidence.connection_evidence(); + assert_eq!(connection.origin(), &target); + assert_eq!(connection.requested_address(), approved); + assert_eq!(connection.canonical_address(), approved); + assert_eq!(connection.address_class(), AddressClass::Public); assert_eq!(evidence.resolution_approved_at(), approved_at); assert_eq!(evidence.resolution_valid_until(), Duration::from_secs(105)); assert_eq!(evidence.authorized_at(), approved_at); @@ -149,3 +157,39 @@ fn fresh_revalidation_preserves_the_budget_and_resets_approval_time() { }) ); } + +#[test] +fn freshness_errors_have_deterministic_bounded_messages() { + let invalid = DestinationError::InvalidResolutionValidity { + validity: Duration::ZERO, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }; + assert_eq!( + invalid.to_string(), + "resolution validity 0ns is outside 1ns..=30s" + ); + + let overflow = DestinationError::ResolutionValidityOverflow { + approved_at: Duration::MAX, + validity: Duration::from_nanos(1), + }; + assert!(overflow.to_string().contains("overflows approval time")); + + let before = DestinationError::ResolutionUseBeforeApproval { + approved_at: Duration::from_secs(10), + current_time: Duration::from_secs(9), + }; + assert_eq!( + before.to_string(), + "resolution use time 9s precedes approval time 10s" + ); + + let expired = DestinationError::ResolutionApprovalExpired { + valid_until: Duration::from_secs(15), + current_time: Duration::from_secs(15), + }; + assert_eq!( + expired.to_string(), + "resolution approval expired at 15s; current time is 15s" + ); +} From 8acccf8f3246de79608c52487cb6a97b8fb95522 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 19:00:58 +0900 Subject: [PATCH 017/570] test(tls): require bounded revocation freshness authority --- .../tests/revocation_freshness.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 crates/originweave-tls/tests/revocation_freshness.rs diff --git a/crates/originweave-tls/tests/revocation_freshness.rs b/crates/originweave-tls/tests/revocation_freshness.rs new file mode 100644 index 000000000..65d2c6a2a --- /dev/null +++ b/crates/originweave-tls/tests/revocation_freshness.rs @@ -0,0 +1,57 @@ +use std::error::Error as _; + +use originweave_tls::{RevocationMaterialFreshness, RevocationMaterialFreshnessError}; + +#[test] +fn revocation_material_freshness_uses_a_half_open_verified_window() { + let freshness = RevocationMaterialFreshness::new(1_000, 1_100); + assert!(freshness.is_ok()); + + if let Ok(freshness) = freshness { + assert_eq!(freshness.this_update_unix_seconds(), 1_000); + assert_eq!(freshness.next_update_unix_seconds(), 1_100); + assert_eq!(freshness.evaluate(1_000), Ok(())); + assert_eq!(freshness.evaluate(1_099), Ok(())); + assert_eq!( + freshness.evaluate(999), + Err(RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds: 999, + this_update_unix_seconds: 1_000, + }) + ); + assert_eq!( + freshness.evaluate(1_100), + Err(RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds: 1_100, + next_update_unix_seconds: 1_100, + }) + ); + } +} + +#[test] +fn revocation_material_freshness_rejects_empty_or_reversed_windows() { + for (this_update, next_update) in [(1_000, 1_000), (1_001, 1_000)] { + assert_eq!( + RevocationMaterialFreshness::new(this_update, next_update), + Err(RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds: this_update, + next_update_unix_seconds: next_update, + }) + ); + } +} + +#[test] +fn revocation_freshness_errors_are_stable_and_source_free() { + let error = RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds: 1_100, + next_update_unix_seconds: 1_100, + }; + + assert_eq!( + error.to_string(), + "revocation material is stale at trusted time 1100; nextUpdate is 1100" + ); + assert!(error.source().is_none()); +} From 81a0073497b7d86d69acc341aa9140e163b0b824 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 19:04:22 +0900 Subject: [PATCH 018/570] feat(tls): add revocation freshness authority --- crates/originweave-tls/src/revocation.rs | 131 +++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 crates/originweave-tls/src/revocation.rs diff --git a/crates/originweave-tls/src/revocation.rs b/crates/originweave-tls/src/revocation.rs new file mode 100644 index 000000000..743220fdf --- /dev/null +++ b/crates/originweave-tls/src/revocation.rs @@ -0,0 +1,131 @@ +use std::fmt; + +/// A deterministic freshness window for independently verified revocation material. +/// +/// This value does not fetch, parse, authenticate, or interpret OCSP responses or +/// certificate revocation lists. A trusted adapter must first obtain and +/// cryptographically validate the revocation material, then pass the signed +/// `thisUpdate` and `nextUpdate` timestamps into this authority. Passing this +/// check proves only that the supplied material is within its declared freshness +/// window; it does not prove that any certificate is unrevoked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RevocationMaterialFreshness { + this_update_unix_seconds: u64, + next_update_unix_seconds: u64, +} + +impl RevocationMaterialFreshness { + /// Create a non-empty freshness window from trusted signed timestamps. + /// + /// The window is half-open: `thisUpdate <= trusted_time < nextUpdate`. + /// Equal or reversed timestamps fail closed because they provide no usable + /// interval in which a caller can rely on the material as current. + pub const fn new( + this_update_unix_seconds: u64, + next_update_unix_seconds: u64, + ) -> Result { + if next_update_unix_seconds <= this_update_unix_seconds { + Err(RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds, + next_update_unix_seconds, + }) + } else { + Ok(Self { + this_update_unix_seconds, + next_update_unix_seconds, + }) + } + } + + /// Return the signed time at which the revocation material becomes current. + #[must_use] + pub const fn this_update_unix_seconds(self) -> u64 { + self.this_update_unix_seconds + } + + /// Return the signed time at which this freshness window stops being usable. + #[must_use] + pub const fn next_update_unix_seconds(self) -> u64 { + self.next_update_unix_seconds + } + + /// Evaluate one trusted time against the half-open freshness window. + /// + /// A time before `thisUpdate` is not yet usable. A time equal to or later + /// than `nextUpdate` is stale. Both cases fail closed without making any + /// statement about the certificate's revocation state. + pub const fn evaluate( + self, + trusted_time_unix_seconds: u64, + ) -> Result<(), RevocationMaterialFreshnessError> { + if trusted_time_unix_seconds < self.this_update_unix_seconds { + Err(RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds, + this_update_unix_seconds: self.this_update_unix_seconds, + }) + } else if trusted_time_unix_seconds >= self.next_update_unix_seconds { + Err(RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds, + next_update_unix_seconds: self.next_update_unix_seconds, + }) + } else { + Ok(()) + } + } +} + +/// A deterministic reason that verified revocation material is not fresh enough to use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RevocationMaterialFreshnessError { + /// The supplied signed timestamps do not define a non-empty freshness window. + InvalidWindow { + /// Signed `thisUpdate` timestamp in Unix seconds. + this_update_unix_seconds: u64, + /// Signed `nextUpdate` timestamp in Unix seconds. + next_update_unix_seconds: u64, + }, + /// Trusted time falls before the material's signed `thisUpdate` timestamp. + NotYetValid { + /// Trusted evaluation time in Unix seconds. + trusted_time_unix_seconds: u64, + /// Signed `thisUpdate` timestamp in Unix seconds. + this_update_unix_seconds: u64, + }, + /// Trusted time is equal to or later than the material's signed `nextUpdate` timestamp. + Expired { + /// Trusted evaluation time in Unix seconds. + trusted_time_unix_seconds: u64, + /// Signed `nextUpdate` timestamp in Unix seconds. + next_update_unix_seconds: u64, + }, +} + +impl fmt::Display for RevocationMaterialFreshnessError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidWindow { + this_update_unix_seconds, + next_update_unix_seconds, + } => write!( + formatter, + "revocation material window is invalid: thisUpdate {this_update_unix_seconds} must be before nextUpdate {next_update_unix_seconds}", + ), + Self::NotYetValid { + trusted_time_unix_seconds, + this_update_unix_seconds, + } => write!( + formatter, + "revocation material is not usable at trusted time {trusted_time_unix_seconds}; thisUpdate is {this_update_unix_seconds}", + ), + Self::Expired { + trusted_time_unix_seconds, + next_update_unix_seconds, + } => write!( + formatter, + "revocation material is stale at trusted time {trusted_time_unix_seconds}; nextUpdate is {next_update_unix_seconds}", + ), + } + } +} + +impl std::error::Error for RevocationMaterialFreshnessError {} From 19f93ac67bd86fc30ae84945e2b28a269432ee1b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 19:04:52 +0900 Subject: [PATCH 019/570] feat(tls): export revocation freshness authority --- crates/originweave-tls/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/originweave-tls/src/lib.rs b/crates/originweave-tls/src/lib.rs index f9ec5e877..9024946f4 100644 --- a/crates/originweave-tls/src/lib.rs +++ b/crates/originweave-tls/src/lib.rs @@ -14,6 +14,7 @@ mod evidence; mod handshake; mod identity; mod policy; +mod revocation; mod trust; mod validity; @@ -29,6 +30,7 @@ pub use policy::{ MAX_MINIMUM_LEAF_VALIDITY, MAX_SERVER_CERTIFICATE_BYTES, MAX_SERVER_CERTIFICATE_COUNT, MAX_TLS_HANDSHAKE_TIMEOUT, TlsClientPolicy, }; +pub use revocation::{RevocationMaterialFreshness, RevocationMaterialFreshnessError}; pub use trust::{ MAX_TRUST_ROOT_BYTES, MAX_TRUST_ROOT_COUNT, TrustBundleIdentifier, TrustRootBundle, }; From 4f76fa89dd00fab069943ba9d7025908c854a242 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 19:05:24 +0900 Subject: [PATCH 020/570] test(tls): cover revocation freshness error contract --- .../tests/revocation_freshness.rs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/originweave-tls/tests/revocation_freshness.rs b/crates/originweave-tls/tests/revocation_freshness.rs index 65d2c6a2a..fefc1c091 100644 --- a/crates/originweave-tls/tests/revocation_freshness.rs +++ b/crates/originweave-tls/tests/revocation_freshness.rs @@ -44,14 +44,33 @@ fn revocation_material_freshness_rejects_empty_or_reversed_windows() { #[test] fn revocation_freshness_errors_are_stable_and_source_free() { - let error = RevocationMaterialFreshnessError::Expired { + let invalid = RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds: 1_000, + next_update_unix_seconds: 1_000, + }; + let future = RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds: 999, + this_update_unix_seconds: 1_000, + }; + let stale = RevocationMaterialFreshnessError::Expired { trusted_time_unix_seconds: 1_100, next_update_unix_seconds: 1_100, }; assert_eq!( - error.to_string(), + invalid.to_string(), + "revocation material window is invalid: thisUpdate 1000 must be before nextUpdate 1000" + ); + assert_eq!( + future.to_string(), + "revocation material is not usable at trusted time 999; thisUpdate is 1000" + ); + assert_eq!( + stale.to_string(), "revocation material is stale at trusted time 1100; nextUpdate is 1100" ); - assert!(error.source().is_none()); + + for error in [invalid, future, stale] { + assert!(error.source().is_none()); + } } From 9bbe12860436027a3b7cd5786775f1dacfbc835d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 19:06:37 +0900 Subject: [PATCH 021/570] docs(changelog): record revocation freshness authority --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4bd39ca..2955f6c4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. - Authenticated `originweave-tls` service-identity boundary that consumes an existing verified TCP stream, requires exact TLS-origin and transport-origin equality, derives RFC 9525 DNS or literal-IP reference identity only from the canonical HTTPS origin, validates WebPKI with explicit roots and fixed time, permits only TLS 1.2 and TLS 1.3, and never reconnects or resolves. - Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior and `NotConfigured` revocation evidence. +- Deterministic TLS revocation-material freshness authority with a strict signed `thisUpdate`→`nextUpdate` half-open window and typed invalid-window, not-yet-valid, and stale failures, without claiming OCSP/CRL acquisition, cryptographic validation, or certificate revocation status. - Credential-free TLS evidence containing canonical origin, requested and observed peer, DNS/IP reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration. - Credential-free connection and redirect evidence containing canonical addresses, destination classes, target digests, hop numbers, and approved-address counts. - Credential-free verified TCP evidence containing the logical origin, requested socket, observed peer, destination class, successful attempt number, and per-attempt timeout. From 41ded6f27883d6c35443d0443d7131207fe56987 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 19:18:16 +0900 Subject: [PATCH 022/570] style(destination): restore canonical rustfmt newline --- crates/originweave-destination/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-destination/src/lib.rs b/crates/originweave-destination/src/lib.rs index 0b27014ba..774ba9ee9 100644 --- a/crates/originweave-destination/src/lib.rs +++ b/crates/originweave-destination/src/lib.rs @@ -27,4 +27,4 @@ pub use resolution::{ ConnectionEvidence, DestinationError, DestinationPolicy, FreshConnectionEvidence, FreshResolutionSnapshot, MAX_RESOLUTION_ADDRESS_COUNT, MAX_RESOLUTION_VALIDITY, ResolutionSnapshot, -}; \ No newline at end of file +}; From e03836f5e2e23eeb4cb89a1b08b46da70923718b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 19:20:10 +0900 Subject: [PATCH 023/570] test(destination): reject denied addresses before freshness authority --- .../tests/resolution_freshness.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/originweave-destination/tests/resolution_freshness.rs b/crates/originweave-destination/tests/resolution_freshness.rs index d47fd8821..79349d6dd 100644 --- a/crates/originweave-destination/tests/resolution_freshness.rs +++ b/crates/originweave-destination/tests/resolution_freshness.rs @@ -115,6 +115,39 @@ fn fresh_resolution_rejects_invalid_or_overflowing_validity() { ); } +#[test] +fn fresh_resolution_rejects_denied_addresses_before_granting_time_authority() { + let target = origin("https://example.com"); + let denied = ipv4(127, 0, 0, 1); + let public = ipv4(8, 8, 8, 8); + let policy = DestinationPolicy::public_web(); + let expected = Err(DestinationError::AddressClassDenied { + address: denied, + address_class: AddressClass::Loopback, + }); + + assert_eq!( + FreshResolutionSnapshot::approve( + target.clone(), + [denied], + &policy, + Duration::from_secs(1), + Duration::from_secs(1), + ), + expected.clone() + ); + assert_eq!( + FreshResolutionSnapshot::approve( + target, + [denied, public], + &policy, + Duration::from_secs(1), + Duration::from_secs(1), + ), + expected + ); +} + #[test] fn fresh_revalidation_preserves_the_budget_and_resets_approval_time() { let first = ipv4(8, 8, 8, 8); From 6b5ed4dcea281b505f67db6180bb14c3bc95b392 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 20:07:59 +0900 Subject: [PATCH 024/570] test(destination): cover single-address rebinding rejection --- .../tests/resolution_freshness.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/originweave-destination/tests/resolution_freshness.rs b/crates/originweave-destination/tests/resolution_freshness.rs index 79349d6dd..2df264563 100644 --- a/crates/originweave-destination/tests/resolution_freshness.rs +++ b/crates/originweave-destination/tests/resolution_freshness.rs @@ -152,6 +152,7 @@ fn fresh_resolution_rejects_denied_addresses_before_granting_time_authority() { fn fresh_revalidation_preserves_the_budget_and_resets_approval_time() { let first = ipv4(8, 8, 8, 8); let second = ipv4(1, 1, 1, 1); + let unexpected = ipv4(9, 9, 9, 9); let policy = DestinationPolicy::public_web(); let snapshot = FreshResolutionSnapshot::approve( origin("https://example.com"), @@ -184,9 +185,15 @@ fn fresh_revalidation_preserves_the_budget_and_resets_approval_time() { }) ); assert_eq!( - snapshot.revalidate([first, ipv4(9, 9, 9, 9)], &policy, Duration::from_secs(11)), + snapshot.revalidate([unexpected], &policy, Duration::from_secs(11)), Err(DestinationError::ResolutionSetExpanded { - address: ipv4(9, 9, 9, 9), + address: unexpected, + }) + ); + assert_eq!( + snapshot.revalidate([first, unexpected], &policy, Duration::from_secs(11)), + Err(DestinationError::ResolutionSetExpanded { + address: unexpected, }) ); } From 277df965602a97b1c221df2fc7a228ff5ac6c540 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 06:55:26 +0900 Subject: [PATCH 025/570] test(policy): prove extension grant cannot widen agent authority --- .../tests/extension_policy_isolation.rs | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 crates/originweave-policy/tests/extension_policy_isolation.rs diff --git a/crates/originweave-policy/tests/extension_policy_isolation.rs b/crates/originweave-policy/tests/extension_policy_isolation.rs new file mode 100644 index 000000000..cf65e0a2a --- /dev/null +++ b/crates/originweave-policy/tests/extension_policy_isolation.rs @@ -0,0 +1,148 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, + evaluate_extension_access, +}; +use originweave_policy::{Decision, DenialReason, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(7).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(11).expect("nonzero browsing context") +} + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_extension_can_only_propose(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + browser_session(), + browsing_context(), + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +#[test] +fn explicit_extension_grant_does_not_widen_agent_origin_authority() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let allowed = origin("https://app.example"); + let forbidden = origin("https://outside.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([allowed.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + allowed, + forbidden, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::OriginNotReadable) + ); +} + +#[test] +fn explicit_extension_grant_does_not_supply_agent_action_capability() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::MissingCapability(Capability::Navigate)) + ); +} + +#[test] +fn untrusted_extension_content_cannot_become_a_policy_instruction() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::WebContent, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::UntrustedInstructionSource) + ); +} From a57873b3688984711918be17aadd348ed9fb12a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:05:55 +0900 Subject: [PATCH 026/570] test(policy): keep extension proposals outside secret authority --- .../tests/extension_policy_isolation.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/originweave-policy/tests/extension_policy_isolation.rs b/crates/originweave-policy/tests/extension_policy_isolation.rs index cf65e0a2a..79557e49b 100644 --- a/crates/originweave-policy/tests/extension_policy_isolation.rs +++ b/crates/originweave-policy/tests/extension_policy_isolation.rs @@ -146,3 +146,63 @@ fn untrusted_extension_content_cannot_become_a_policy_instruction() { Decision::Deny(DenialReason::UntrustedInstructionSource) ); } + +#[test] +fn explicit_extension_grant_cannot_turn_raw_secret_delivery_into_a_fill_capability() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::FillSecret]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::FillSecret, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::RawValue, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::SecretBrokerRequired) + ); +} + +#[test] +fn explicit_extension_grant_cannot_attach_secret_material_to_non_secret_action() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::RawValue, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::UnexpectedSecretMaterial) + ); +} From 3059fead1ef0b6cf2f7df765b03c4b00a669b9cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:05:56 +0900 Subject: [PATCH 027/570] test(policy): prove extension grants cannot disclose secrets --- .../tests/extension_secret_isolation.rs | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 crates/originweave-policy/tests/extension_secret_isolation.rs diff --git a/crates/originweave-policy/tests/extension_secret_isolation.rs b/crates/originweave-policy/tests/extension_secret_isolation.rs new file mode 100644 index 000000000..2ddd754f0 --- /dev/null +++ b/crates/originweave-policy/tests/extension_secret_isolation.rs @@ -0,0 +1,141 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, + SessionMode, evaluate_extension_access, +}; +use originweave_policy::{Decision, DenialReason, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(7).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(11).expect("nonzero browsing context") +} + +fn origin() -> Origin { + Origin::parse("https://login.example").expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_extension_can_propose(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + browser_session(), + browsing_context(), + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +fn secret_context(site: &Origin) -> PolicyContext { + PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::FillSecret]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ) +} + +#[test] +fn extension_action_grant_cannot_turn_raw_secret_delivery_into_authority() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin(); + let proposed = ActionRequest::new( + ActionKind::FillSecret, + site.clone(), + site.clone(), + InstructionSource::User, + SecretDelivery::RawValue, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &secret_context(&site)), + Decision::Deny(DenialReason::SecretBrokerRequired) + ); +} + +#[test] +fn extension_action_grant_cannot_skip_secret_broker_approval() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin(); + let proposed = ActionRequest::new( + ActionKind::FillSecret, + site.clone(), + site.clone(), + InstructionSource::User, + SecretDelivery::BrokerHandle, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &secret_context(&site)), + Decision::RequireApproval(RiskClass::R3) + ); +} + +#[test] +fn extension_action_grant_cannot_attach_broker_material_to_nonsecret_actions() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin(); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::BrokerHandle, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::UnexpectedSecretMaterial) + ); +} From e83749acd1cf5a0b778ba38eb9d6ed5a9bd1e68f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:22:02 +0900 Subject: [PATCH 028/570] test(policy): keep only unique extension approval boundary --- .../tests/extension_secret_isolation.rs | 53 +------------------ 1 file changed, 1 insertion(+), 52 deletions(-) diff --git a/crates/originweave-policy/tests/extension_secret_isolation.rs b/crates/originweave-policy/tests/extension_secret_isolation.rs index 2ddd754f0..ad4293d0a 100644 --- a/crates/originweave-policy/tests/extension_secret_isolation.rs +++ b/crates/originweave-policy/tests/extension_secret_isolation.rs @@ -9,7 +9,7 @@ use originweave_core::{ InstructionSource, Origin, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, SessionMode, evaluate_extension_access, }; -use originweave_policy::{Decision, DenialReason, evaluate}; +use originweave_policy::{Decision, evaluate}; const VALID_INTENT: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -68,27 +68,6 @@ fn secret_context(site: &Origin) -> PolicyContext { ) } -#[test] -fn extension_action_grant_cannot_turn_raw_secret_delivery_into_authority() { - let grant = action_proposal_grant(); - assert_extension_can_propose(&grant); - - let site = origin(); - let proposed = ActionRequest::new( - ActionKind::FillSecret, - site.clone(), - site.clone(), - InstructionSource::User, - SecretDelivery::RawValue, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &secret_context(&site)), - Decision::Deny(DenialReason::SecretBrokerRequired) - ); -} - #[test] fn extension_action_grant_cannot_skip_secret_broker_approval() { let grant = action_proposal_grant(); @@ -109,33 +88,3 @@ fn extension_action_grant_cannot_skip_secret_broker_approval() { Decision::RequireApproval(RiskClass::R3) ); } - -#[test] -fn extension_action_grant_cannot_attach_broker_material_to_nonsecret_actions() { - let grant = action_proposal_grant(); - assert_extension_can_propose(&grant); - - let site = origin(); - let context = PolicyContext::new( - SessionMode::AgentTask, - ExecutionPurpose::UserDelegatedTask, - BTreeSet::from([Capability::Observe]), - BTreeSet::from([site.clone()]), - BTreeSet::new(), - RobotsDecision::Allowed, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::Observe, - site.clone(), - site, - InstructionSource::User, - SecretDelivery::BrokerHandle, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::UnexpectedSecretMaterial) - ); -} From ec3f347ec019dbb25de027f1d96cb818aa3aad9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:51:31 +0900 Subject: [PATCH 029/570] test(tls): bound accepted revocation freshness interval --- .../tests/revocation_freshness.rs | 50 +++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/originweave-tls/tests/revocation_freshness.rs b/crates/originweave-tls/tests/revocation_freshness.rs index fefc1c091..fb7df376a 100644 --- a/crates/originweave-tls/tests/revocation_freshness.rs +++ b/crates/originweave-tls/tests/revocation_freshness.rs @@ -2,14 +2,17 @@ use std::error::Error as _; use originweave_tls::{RevocationMaterialFreshness, RevocationMaterialFreshnessError}; +const MAXIMUM_WINDOW_SECONDS: u64 = 300; + #[test] fn revocation_material_freshness_uses_a_half_open_verified_window() { - let freshness = RevocationMaterialFreshness::new(1_000, 1_100); + let freshness = RevocationMaterialFreshness::new(1_000, 1_100, MAXIMUM_WINDOW_SECONDS); assert!(freshness.is_ok()); if let Ok(freshness) = freshness { assert_eq!(freshness.this_update_unix_seconds(), 1_000); assert_eq!(freshness.next_update_unix_seconds(), 1_100); + assert_eq!(freshness.maximum_window_seconds(), MAXIMUM_WINDOW_SECONDS); assert_eq!(freshness.evaluate(1_000), Ok(())); assert_eq!(freshness.evaluate(1_099), Ok(())); assert_eq!( @@ -33,7 +36,7 @@ fn revocation_material_freshness_uses_a_half_open_verified_window() { fn revocation_material_freshness_rejects_empty_or_reversed_windows() { for (this_update, next_update) in [(1_000, 1_000), (1_001, 1_000)] { assert_eq!( - RevocationMaterialFreshness::new(this_update, next_update), + RevocationMaterialFreshness::new(this_update, next_update, MAXIMUM_WINDOW_SECONDS), Err(RevocationMaterialFreshnessError::InvalidWindow { this_update_unix_seconds: this_update, next_update_unix_seconds: next_update, @@ -42,12 +45,45 @@ fn revocation_material_freshness_rejects_empty_or_reversed_windows() { } } +#[test] +fn revocation_material_freshness_requires_a_bounded_local_policy_window() { + assert_eq!( + RevocationMaterialFreshness::new(1_000, 1_100, 0), + Err(RevocationMaterialFreshnessError::ZeroMaximumWindow) + ); + + let exact_maximum = + RevocationMaterialFreshness::new(1_000, 1_300, MAXIMUM_WINDOW_SECONDS); + assert!(exact_maximum.is_ok()); + + assert_eq!( + RevocationMaterialFreshness::new(1_000, 1_301, MAXIMUM_WINDOW_SECONDS), + Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: 301, + maximum_window_seconds: MAXIMUM_WINDOW_SECONDS, + }) + ); + + assert_eq!( + RevocationMaterialFreshness::new(1, u64::MAX, 1), + Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: u64::MAX - 1, + maximum_window_seconds: 1, + }) + ); +} + #[test] fn revocation_freshness_errors_are_stable_and_source_free() { let invalid = RevocationMaterialFreshnessError::InvalidWindow { this_update_unix_seconds: 1_000, next_update_unix_seconds: 1_000, }; + let zero_maximum = RevocationMaterialFreshnessError::ZeroMaximumWindow; + let too_long = RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: 301, + maximum_window_seconds: MAXIMUM_WINDOW_SECONDS, + }; let future = RevocationMaterialFreshnessError::NotYetValid { trusted_time_unix_seconds: 999, this_update_unix_seconds: 1_000, @@ -61,6 +97,14 @@ fn revocation_freshness_errors_are_stable_and_source_free() { invalid.to_string(), "revocation material window is invalid: thisUpdate 1000 must be before nextUpdate 1000" ); + assert_eq!( + zero_maximum.to_string(), + "revocation material maximum freshness window must be greater than zero" + ); + assert_eq!( + too_long.to_string(), + "revocation material window is 301 seconds, exceeding the local maximum of 300 seconds" + ); assert_eq!( future.to_string(), "revocation material is not usable at trusted time 999; thisUpdate is 1000" @@ -70,7 +114,7 @@ fn revocation_freshness_errors_are_stable_and_source_free() { "revocation material is stale at trusted time 1100; nextUpdate is 1100" ); - for error in [invalid, future, stale] { + for error in [invalid, zero_maximum, too_long, future, stale] { assert!(error.source().is_none()); } } From d4117e470d9f49073ca3d2662bf69b87a9c5144e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:52:29 +0900 Subject: [PATCH 030/570] style(tls): apply canonical revocation test formatting --- crates/originweave-tls/tests/revocation_freshness.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-tls/tests/revocation_freshness.rs b/crates/originweave-tls/tests/revocation_freshness.rs index fb7df376a..c7af7bd7c 100644 --- a/crates/originweave-tls/tests/revocation_freshness.rs +++ b/crates/originweave-tls/tests/revocation_freshness.rs @@ -52,8 +52,7 @@ fn revocation_material_freshness_requires_a_bounded_local_policy_window() { Err(RevocationMaterialFreshnessError::ZeroMaximumWindow) ); - let exact_maximum = - RevocationMaterialFreshness::new(1_000, 1_300, MAXIMUM_WINDOW_SECONDS); + let exact_maximum = RevocationMaterialFreshness::new(1_000, 1_300, MAXIMUM_WINDOW_SECONDS); assert!(exact_maximum.is_ok()); assert_eq!( From f3e23620d50c59606b76bcca0f193647933d8525 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:54:59 +0900 Subject: [PATCH 031/570] feat(tls): cap revocation freshness by local policy --- crates/originweave-tls/src/revocation.rs | 69 +++++++++++++++++++----- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/crates/originweave-tls/src/revocation.rs b/crates/originweave-tls/src/revocation.rs index 743220fdf..e500125a2 100644 --- a/crates/originweave-tls/src/revocation.rs +++ b/crates/originweave-tls/src/revocation.rs @@ -5,36 +5,53 @@ use std::fmt; /// This value does not fetch, parse, authenticate, or interpret OCSP responses or /// certificate revocation lists. A trusted adapter must first obtain and /// cryptographically validate the revocation material, then pass the signed -/// `thisUpdate` and `nextUpdate` timestamps into this authority. Passing this -/// check proves only that the supplied material is within its declared freshness -/// window; it does not prove that any certificate is unrevoked. +/// `thisUpdate` and `nextUpdate` timestamps into this authority together with a +/// caller-selected local maximum freshness window. Passing this check proves only +/// that the supplied material is within both its signed interval and the caller's +/// bounded freshness policy; it does not prove that any certificate is unrevoked. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RevocationMaterialFreshness { this_update_unix_seconds: u64, next_update_unix_seconds: u64, + maximum_window_seconds: u64, } impl RevocationMaterialFreshness { - /// Create a non-empty freshness window from trusted signed timestamps. + /// Create a non-empty, locally bounded freshness window from trusted signed timestamps. /// - /// The window is half-open: `thisUpdate <= trusted_time < nextUpdate`. + /// The signed window is half-open: `thisUpdate <= trusted_time < nextUpdate`. /// Equal or reversed timestamps fail closed because they provide no usable - /// interval in which a caller can rely on the material as current. + /// interval. `maximum_window_seconds` is a separate local policy ceiling and + /// must be nonzero; signed material whose declared interval exceeds that + /// ceiling is rejected even if its timestamps are otherwise well-formed. pub const fn new( this_update_unix_seconds: u64, next_update_unix_seconds: u64, + maximum_window_seconds: u64, ) -> Result { if next_update_unix_seconds <= this_update_unix_seconds { - Err(RevocationMaterialFreshnessError::InvalidWindow { + return Err(RevocationMaterialFreshnessError::InvalidWindow { this_update_unix_seconds, next_update_unix_seconds, - }) - } else { - Ok(Self { - this_update_unix_seconds, - next_update_unix_seconds, - }) + }); } + if maximum_window_seconds == 0 { + return Err(RevocationMaterialFreshnessError::ZeroMaximumWindow); + } + + let window_seconds = next_update_unix_seconds - this_update_unix_seconds; + if window_seconds > maximum_window_seconds { + return Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds, + maximum_window_seconds, + }); + } + + Ok(Self { + this_update_unix_seconds, + next_update_unix_seconds, + maximum_window_seconds, + }) } /// Return the signed time at which the revocation material becomes current. @@ -49,6 +66,12 @@ impl RevocationMaterialFreshness { self.next_update_unix_seconds } + /// Return the caller-selected maximum accepted signed-window duration. + #[must_use] + pub const fn maximum_window_seconds(self) -> u64 { + self.maximum_window_seconds + } + /// Evaluate one trusted time against the half-open freshness window. /// /// A time before `thisUpdate` is not yet usable. A time equal to or later @@ -84,6 +107,15 @@ pub enum RevocationMaterialFreshnessError { /// Signed `nextUpdate` timestamp in Unix seconds. next_update_unix_seconds: u64, }, + /// The caller supplied no positive local maximum freshness duration. + ZeroMaximumWindow, + /// The material's signed interval exceeds the caller's local freshness ceiling. + WindowExceedsMaximum { + /// Duration of the signed `thisUpdate` to `nextUpdate` interval in seconds. + window_seconds: u64, + /// Caller-selected maximum accepted interval in seconds. + maximum_window_seconds: u64, + }, /// Trusted time falls before the material's signed `thisUpdate` timestamp. NotYetValid { /// Trusted evaluation time in Unix seconds. @@ -110,6 +142,17 @@ impl fmt::Display for RevocationMaterialFreshnessError { formatter, "revocation material window is invalid: thisUpdate {this_update_unix_seconds} must be before nextUpdate {next_update_unix_seconds}", ), + Self::ZeroMaximumWindow => write!( + formatter, + "revocation material maximum freshness window must be greater than zero", + ), + Self::WindowExceedsMaximum { + window_seconds, + maximum_window_seconds, + } => write!( + formatter, + "revocation material window is {window_seconds} seconds, exceeding the local maximum of {maximum_window_seconds} seconds", + ), Self::NotYetValid { trusted_time_unix_seconds, this_update_unix_seconds, From d2580305f05aba93d10b5342ec1886d601c6752e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:10:03 +0900 Subject: [PATCH 032/570] test(browser): define controlled Agent Task fixture contract --- tests/test_agent_task_fixture_contract.py | 89 +++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/test_agent_task_fixture_contract.py diff --git a/tests/test_agent_task_fixture_contract.py b/tests/test_agent_task_fixture_contract.py new file mode 100644 index 000000000..2a1465b2e --- /dev/null +++ b/tests/test_agent_task_fixture_contract.py @@ -0,0 +1,89 @@ +"""Fail-first contract for the controlled Chromium Agent Task fixture.""" + +from __future__ import annotations + +from html.parser import HTMLParser +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests" / "fixtures" / "agent_task_basic" / "index.html" + + +class _FixtureParser(HTMLParser): + """Collect the small semantic surface required by the deterministic fixture.""" + + def __init__(self) -> None: + super().__init__() + self.ids: set[str] = set() + self.labels_for: set[str] = set() + self.input_names: set[str] = set() + self.button_types: set[str] = set() + self.hidden_injection_markers = 0 + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + attributes = dict(attrs) + element_id = attributes.get("id") + if element_id: + self.ids.add(element_id) + if tag == "label" and attributes.get("for"): + self.labels_for.add(attributes["for"]) + if tag == "input" and attributes.get("name"): + self.input_names.add(attributes["name"]) + if tag == "button" and attributes.get("type"): + self.button_types.add(attributes["type"]) + if ( + attributes.get("data-originweave-untrusted") == "prompt-injection" + and attributes.get("aria-hidden") == "true" + ): + self.hidden_injection_markers += 1 + + +class AgentTaskFixtureContractTests(unittest.TestCase): + """Require one deterministic semantic workflow for the first browser slice.""" + + def setUp(self) -> None: + """Load the checked-in fixture once for each independent contract.""" + + self.html = FIXTURE.read_text(encoding="utf-8") + self.parser = _FixtureParser() + self.parser.feed(self.html) + + def test_fixture_exposes_semantic_form_and_observable_post_condition(self) -> None: + """The fixture must support role/name discovery and a deterministic state change.""" + + self.assertIn("task-text", self.parser.ids) + self.assertIn("task-text", self.parser.labels_for) + self.assertIn("task_text", self.parser.input_names) + self.assertIn("submit", self.parser.button_types) + self.assertIn("task-result", self.parser.ids) + self.assertIn('data-state="idle"', self.html) + self.assertIn('result.dataset.state = "submitted"', self.html) + self.assertIn("result.textContent = taskText.value", self.html) + + def test_fixture_contains_explicit_untrusted_hidden_prompt_injection(self) -> None: + """A later real-browser regression needs hostile hidden page content to ignore.""" + + self.assertEqual(self.parser.hidden_injection_markers, 1) + self.assertIn("UNTRUSTED_PAGE_INSTRUCTION", self.html) + self.assertIn("request new browser capabilities", self.html) + + def test_fixture_is_synthetic_and_has_no_credential_fields(self) -> None: + """The controlled workflow must not require or imitate real secret collection.""" + + lowered = self.html.lower() + for forbidden in ( + 'type="password"', + 'autocomplete="current-password"', + 'autocomplete="one-time-code"', + "api_key", + "secret_key", + ): + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, lowered) + + +if __name__ == "__main__": + unittest.main() From 0888fe3a6ef6da547a37fd075733cc73dc52b2ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 09:12:01 +0900 Subject: [PATCH 033/570] test(browser): add controlled Agent Task fixture --- tests/fixtures/agent_task_basic/index.html | 42 ++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/fixtures/agent_task_basic/index.html diff --git a/tests/fixtures/agent_task_basic/index.html b/tests/fixtures/agent_task_basic/index.html new file mode 100644 index 000000000..510b239f1 --- /dev/null +++ b/tests/fixtures/agent_task_basic/index.html @@ -0,0 +1,42 @@ + + + + + + OriginWeave controlled Agent Task fixture + + +
+

Controlled Agent Task

+

This page is synthetic test data for deterministic browser integration.

+ +
+ + + +
+ + idle + + +
+ + + + From 2403344abf4b9ec8d36b58f48f3ab2abe591cff4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:28:53 +0900 Subject: [PATCH 034/570] test(policy): prove extension grants cannot widen mutation authority --- .../tests/extension_mutation_isolation.rs | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 crates/originweave-policy/tests/extension_mutation_isolation.rs diff --git a/crates/originweave-policy/tests/extension_mutation_isolation.rs b/crates/originweave-policy/tests/extension_mutation_isolation.rs new file mode 100644 index 000000000..4a872ce49 --- /dev/null +++ b/crates/originweave-policy/tests/extension_mutation_isolation.rs @@ -0,0 +1,118 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, + evaluate_extension_access, +}; +use originweave_policy::{Decision, DenialReason, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(17).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(23).expect("nonzero browsing context") +} + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_extension_can_propose(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + browser_session(), + browsing_context(), + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +#[test] +fn explicit_extension_grant_cannot_authorize_cross_origin_mutation() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let source = origin("https://source.example"); + let target = origin("https://target.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([source.clone(), target.clone()]), + BTreeSet::from([target.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + source, + target, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::CrossOriginMutation) + ); +} + +#[test] +fn explicit_extension_grant_cannot_supply_missing_write_origin_authority() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::OriginNotWritable) + ); +} From fda8ece43f131db3f30431039079c7896e7c8479 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:35:22 +0900 Subject: [PATCH 035/570] test(policy): cover crawler and R5 extension isolation --- .../tests/extension_mutation_isolation.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/originweave-policy/tests/extension_mutation_isolation.rs b/crates/originweave-policy/tests/extension_mutation_isolation.rs index 4a872ce49..0b81b6438 100644 --- a/crates/originweave-policy/tests/extension_mutation_isolation.rs +++ b/crates/originweave-policy/tests/extension_mutation_isolation.rs @@ -116,3 +116,63 @@ fn explicit_extension_grant_cannot_supply_missing_write_origin_authority() { Decision::Deny(DenialReason::OriginNotWritable) ); } + +#[test] +fn explicit_extension_grant_cannot_turn_crawler_mode_into_mutation_authority() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::CrawlerMutation) + ); +} + +#[test] +fn explicit_extension_grant_cannot_delegate_forbidden_r5_action() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin("https://consent.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::LegalConsent]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::LegalConsent, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::ForbiddenRisk) + ); +} From 8133bc91a80105f132aa41b58c4fcff79a91ecb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 13:48:57 +0900 Subject: [PATCH 036/570] test(policy): prove extension grants cannot control human mode --- .../tests/extension_mutation_isolation.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/originweave-policy/tests/extension_mutation_isolation.rs b/crates/originweave-policy/tests/extension_mutation_isolation.rs index 0b81b6438..2499c8cf3 100644 --- a/crates/originweave-policy/tests/extension_mutation_isolation.rs +++ b/crates/originweave-policy/tests/extension_mutation_isolation.rs @@ -176,3 +176,33 @@ fn explicit_extension_grant_cannot_delegate_forbidden_r5_action() { Decision::Deny(DenialReason::ForbiddenRisk) ); } + +#[test] +fn explicit_extension_grant_cannot_turn_human_mode_into_agent_control() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin("https://human.example"); + let context = PolicyContext::new( + SessionMode::Human, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::NotApplicable, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::HumanModeNotAgentControlled) + ); +} From ac8b27ee69229070c382ca2199eaf9ec8b1b12db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 14:42:33 +0900 Subject: [PATCH 037/570] test(policy): bind extension proposals to crawl policy gates --- .../tests/extension_mutation_isolation.rs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/crates/originweave-policy/tests/extension_mutation_isolation.rs b/crates/originweave-policy/tests/extension_mutation_isolation.rs index 2499c8cf3..6c85b983d 100644 --- a/crates/originweave-policy/tests/extension_mutation_isolation.rs +++ b/crates/originweave-policy/tests/extension_mutation_isolation.rs @@ -147,6 +147,126 @@ fn explicit_extension_grant_cannot_turn_crawler_mode_into_mutation_authority() { ); } +#[test] +fn explicit_extension_grant_cannot_pair_agent_task_with_public_crawl_purpose() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::ModePurposeMismatch) + ); +} + +#[test] +fn explicit_extension_grant_cannot_bypass_disallowed_robots_policy() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Disallowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsDisallowed) + ); +} + +#[test] +fn explicit_extension_grant_cannot_bypass_unknown_robots_policy() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Unknown, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsUnknown) + ); +} + +#[test] +fn explicit_extension_grant_cannot_bypass_missing_robots_policy() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::NotApplicable, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsNotApplicable) + ); +} + #[test] fn explicit_extension_grant_cannot_delegate_forbidden_r5_action() { let grant = action_proposal_grant(); From 0d492564aa61c9094f1315ee4e234b46a1e63a6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:14:52 +0900 Subject: [PATCH 038/570] test(policy): clarify independent extension and action boundaries --- .../tests/extension_mutation_isolation.rs | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/crates/originweave-policy/tests/extension_mutation_isolation.rs b/crates/originweave-policy/tests/extension_mutation_isolation.rs index 6c85b983d..97d9b184b 100644 --- a/crates/originweave-policy/tests/extension_mutation_isolation.rs +++ b/crates/originweave-policy/tests/extension_mutation_isolation.rs @@ -1,5 +1,12 @@ #![allow(clippy::expect_used)] +//! Keep extension proposal-grant evaluation separate from ordinary action policy. +//! +//! OriginWeave does not yet implement an adapter that converts an extension proposal into an +//! [`ActionRequest`]. These regressions therefore prove two independent fail-closed boundaries: +//! the exact extension/session/context grant permits only `ProposeTypedAction`, while an ordinary +//! user-sourced action request remains subject to the core policy decision shown in each test. + use std::collections::BTreeSet; use originweave_core::{ @@ -43,7 +50,7 @@ fn action_proposal_grant() -> ExtensionAgentGrant { ) } -fn assert_extension_can_propose(grant: &ExtensionAgentGrant) { +fn assert_proposal_grant_is_independently_allowed(grant: &ExtensionAgentGrant) { let request = ExtensionAccessRequest::new( extension_id(), browser_session(), @@ -57,9 +64,9 @@ fn assert_extension_can_propose(grant: &ExtensionAgentGrant) { } #[test] -fn explicit_extension_grant_cannot_authorize_cross_origin_mutation() { +fn extension_proposal_grant_is_independent_of_cross_origin_mutation_policy() { let grant = action_proposal_grant(); - assert_extension_can_propose(&grant); + assert_proposal_grant_is_independently_allowed(&grant); let source = origin("https://source.example"); let target = origin("https://target.example"); @@ -88,9 +95,9 @@ fn explicit_extension_grant_cannot_authorize_cross_origin_mutation() { } #[test] -fn explicit_extension_grant_cannot_supply_missing_write_origin_authority() { +fn extension_proposal_grant_is_independent_of_write_origin_policy() { let grant = action_proposal_grant(); - assert_extension_can_propose(&grant); + assert_proposal_grant_is_independently_allowed(&grant); let site = origin("https://app.example"); let context = PolicyContext::new( @@ -118,9 +125,9 @@ fn explicit_extension_grant_cannot_supply_missing_write_origin_authority() { } #[test] -fn explicit_extension_grant_cannot_turn_crawler_mode_into_mutation_authority() { +fn extension_proposal_grant_is_independent_of_crawler_mutation_policy() { let grant = action_proposal_grant(); - assert_extension_can_propose(&grant); + assert_proposal_grant_is_independently_allowed(&grant); let site = origin("https://public.example"); let context = PolicyContext::new( @@ -148,9 +155,9 @@ fn explicit_extension_grant_cannot_turn_crawler_mode_into_mutation_authority() { } #[test] -fn explicit_extension_grant_cannot_pair_agent_task_with_public_crawl_purpose() { +fn extension_proposal_grant_is_independent_of_mode_purpose_policy() { let grant = action_proposal_grant(); - assert_extension_can_propose(&grant); + assert_proposal_grant_is_independently_allowed(&grant); let site = origin("https://public.example"); let context = PolicyContext::new( @@ -178,9 +185,9 @@ fn explicit_extension_grant_cannot_pair_agent_task_with_public_crawl_purpose() { } #[test] -fn explicit_extension_grant_cannot_bypass_disallowed_robots_policy() { +fn extension_proposal_grant_is_independent_of_disallowed_robots_policy() { let grant = action_proposal_grant(); - assert_extension_can_propose(&grant); + assert_proposal_grant_is_independently_allowed(&grant); let site = origin("https://public.example"); let context = PolicyContext::new( @@ -208,9 +215,9 @@ fn explicit_extension_grant_cannot_bypass_disallowed_robots_policy() { } #[test] -fn explicit_extension_grant_cannot_bypass_unknown_robots_policy() { +fn extension_proposal_grant_is_independent_of_unknown_robots_policy() { let grant = action_proposal_grant(); - assert_extension_can_propose(&grant); + assert_proposal_grant_is_independently_allowed(&grant); let site = origin("https://public.example"); let context = PolicyContext::new( @@ -238,9 +245,9 @@ fn explicit_extension_grant_cannot_bypass_unknown_robots_policy() { } #[test] -fn explicit_extension_grant_cannot_bypass_missing_robots_policy() { +fn extension_proposal_grant_is_independent_of_missing_robots_policy() { let grant = action_proposal_grant(); - assert_extension_can_propose(&grant); + assert_proposal_grant_is_independently_allowed(&grant); let site = origin("https://public.example"); let context = PolicyContext::new( @@ -268,9 +275,9 @@ fn explicit_extension_grant_cannot_bypass_missing_robots_policy() { } #[test] -fn explicit_extension_grant_cannot_delegate_forbidden_r5_action() { +fn extension_proposal_grant_is_independent_of_non_delegable_r5_policy() { let grant = action_proposal_grant(); - assert_extension_can_propose(&grant); + assert_proposal_grant_is_independently_allowed(&grant); let site = origin("https://consent.example"); let context = PolicyContext::new( @@ -298,9 +305,9 @@ fn explicit_extension_grant_cannot_delegate_forbidden_r5_action() { } #[test] -fn explicit_extension_grant_cannot_turn_human_mode_into_agent_control() { +fn extension_proposal_grant_is_independent_of_human_mode_policy() { let grant = action_proposal_grant(); - assert_extension_can_propose(&grant); + assert_proposal_grant_is_independently_allowed(&grant); let site = origin("https://human.example"); let context = PolicyContext::new( From cd4115e1b7e06717c3fdd8464da191fa3ca0cc60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 23:43:07 +0900 Subject: [PATCH 039/570] test(browser): harden fixture security parsing --- tests/test_agent_task_fixture_contract.py | 66 +++++++++++++++++++---- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/tests/test_agent_task_fixture_contract.py b/tests/test_agent_task_fixture_contract.py index 2a1465b2e..2565a35c7 100644 --- a/tests/test_agent_task_fixture_contract.py +++ b/tests/test_agent_task_fixture_contract.py @@ -10,6 +10,21 @@ FIXTURE = ROOT / "tests" / "fixtures" / "agent_task_basic" / "index.html" +def _is_credential_input(attributes: dict[str, str | None]) -> bool: + """Return whether parsed input attributes describe a credential surface.""" + + input_type = (attributes.get("type") or "").strip().lower() + if input_type == "password": + return True + + autocomplete = (attributes.get("autocomplete") or "").strip().lower() + autocomplete_tokens = autocomplete.split() + return any( + token == "one-time-code" or "password" in token + for token in autocomplete_tokens + ) + + class _FixtureParser(HTMLParser): """Collect the small semantic surface required by the deterministic fixture.""" @@ -18,6 +33,7 @@ def __init__(self) -> None: self.ids: set[str] = set() self.labels_for: set[str] = set() self.input_names: set[str] = set() + self.input_attributes: list[dict[str, str | None]] = [] self.button_types: set[str] = set() self.hidden_injection_markers = 0 @@ -30,12 +46,15 @@ def handle_starttag( self.ids.add(element_id) if tag == "label" and attributes.get("for"): self.labels_for.add(attributes["for"]) - if tag == "input" and attributes.get("name"): - self.input_names.add(attributes["name"]) + if tag == "input": + self.input_attributes.append(attributes) + if attributes.get("name"): + self.input_names.add(attributes["name"]) if tag == "button" and attributes.get("type"): self.button_types.add(attributes["type"]) if ( attributes.get("data-originweave-untrusted") == "prompt-injection" + and "hidden" in attributes and attributes.get("aria-hidden") == "true" ): self.hidden_injection_markers += 1 @@ -70,20 +89,49 @@ def test_fixture_contains_explicit_untrusted_hidden_prompt_injection(self) -> No self.assertIn("UNTRUSTED_PAGE_INSTRUCTION", self.html) self.assertIn("request new browser capabilities", self.html) + def test_hidden_injection_requires_the_actual_hidden_attribute(self) -> None: + """ARIA metadata alone must not satisfy the hidden-injection fixture contract.""" + + parser = _FixtureParser() + parser.feed( + "" + "" + ) + self.assertEqual(parser.hidden_injection_markers, 1) + def test_fixture_is_synthetic_and_has_no_credential_fields(self) -> None: """The controlled workflow must not require or imitate real secret collection.""" + for attributes in self.parser.input_attributes: + with self.subTest(attributes=attributes): + self.assertFalse(_is_credential_input(attributes)) + lowered = self.html.lower() - for forbidden in ( - 'type="password"', - 'autocomplete="current-password"', - 'autocomplete="one-time-code"', - "api_key", - "secret_key", - ): + for forbidden in ("api_key", "secret_key"): with self.subTest(forbidden=forbidden): self.assertNotIn(forbidden, lowered) + def test_credential_detection_is_quote_independent(self) -> None: + """Parsed credential semantics must reject single-quoted and tokenized forms.""" + + for html in ( + "", + "", + "", + "", + "", + ): + with self.subTest(html=html): + parser = _FixtureParser() + parser.feed(html) + self.assertEqual(len(parser.input_attributes), 1) + self.assertTrue(_is_credential_input(parser.input_attributes[0])) + + parser = _FixtureParser() + parser.feed("") + self.assertEqual(len(parser.input_attributes), 1) + self.assertFalse(_is_credential_input(parser.input_attributes[0])) + if __name__ == "__main__": unittest.main() From 6e615ecb9b946943f2e360a4ba4fe3ed8003ce6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 17:41:10 +0900 Subject: [PATCH 040/570] docs(evidence): document sensitive identifier contract --- crates/originweave-evidence/src/sensitive_access.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/originweave-evidence/src/sensitive_access.rs b/crates/originweave-evidence/src/sensitive_access.rs index 625b89804..24cb43047 100644 --- a/crates/originweave-evidence/src/sensitive_access.rs +++ b/crates/originweave-evidence/src/sensitive_access.rs @@ -297,6 +297,9 @@ fn validate_fields(field_ids: &[String]) -> Result<(), SensitiveEvidenceError> { Ok(()) } +/// Return whether `value` is a non-empty identifier of at most +/// `MAX_SENSITIVE_IDENTIFIER_BYTES` ASCII bytes, contains at least one +/// alphanumeric byte, and otherwise uses only `.`, `_`, `:`, or `-` punctuation. pub(crate) fn valid_identifier(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_SENSITIVE_IDENTIFIER_BYTES From fe871b17d07a3a08592144384a780775b7b88526 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 01:19:03 +0900 Subject: [PATCH 041/570] test(resource): require standard budget error contract --- .../tests/error_contract.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 crates/originweave-resource/tests/error_contract.rs diff --git a/crates/originweave-resource/tests/error_contract.rs b/crates/originweave-resource/tests/error_contract.rs new file mode 100644 index 000000000..cc8b88dfb --- /dev/null +++ b/crates/originweave-resource/tests/error_contract.rs @@ -0,0 +1,21 @@ +use originweave_resource::BudgetError; +use std::error::Error as _; + +#[test] +fn budget_errors_expose_stable_standard_error_contract() { + let cases = [ + ( + BudgetError::ZeroLimit, + "resource budget limits must be nonzero", + ), + ( + BudgetError::SoftExceedsHard, + "resource budget soft limits must not exceed hard limits", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + assert!(error.source().is_none()); + } +} From 48979d55832da5fdcd66bea8126d006eb64e8854 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 01:23:11 +0900 Subject: [PATCH 042/570] fix(resource): expose standard budget error contract --- crates/originweave-resource/src/lib.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/originweave-resource/src/lib.rs b/crates/originweave-resource/src/lib.rs index 8c77aa3d0..3bb448a87 100644 --- a/crates/originweave-resource/src/lib.rs +++ b/crates/originweave-resource/src/lib.rs @@ -9,6 +9,8 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +use std::fmt; + /// A validation error in a resource budget. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BudgetError { @@ -18,6 +20,18 @@ pub enum BudgetError { SoftExceedsHard, } +impl fmt::Display for BudgetError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ZeroLimit => formatter.write_str("resource budget limits must be nonzero"), + Self::SoftExceedsHard => formatter + .write_str("resource budget soft limits must not exceed hard limits"), + } + } +} + +impl std::error::Error for BudgetError {} + /// Validated resource limits for one agent task. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ResourceBudget { From b486d6095c09d78e3a0bbf40d63b03a0138f1f89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 01:27:14 +0900 Subject: [PATCH 043/570] style(resource): apply canonical rustfmt --- crates/originweave-resource/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/originweave-resource/src/lib.rs b/crates/originweave-resource/src/lib.rs index 3bb448a87..35a30789a 100644 --- a/crates/originweave-resource/src/lib.rs +++ b/crates/originweave-resource/src/lib.rs @@ -24,8 +24,9 @@ impl fmt::Display for BudgetError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::ZeroLimit => formatter.write_str("resource budget limits must be nonzero"), - Self::SoftExceedsHard => formatter - .write_str("resource budget soft limits must not exceed hard limits"), + Self::SoftExceedsHard => { + formatter.write_str("resource budget soft limits must not exceed hard limits") + } } } } From d9166d89f154962af7a6b2b7902ff3b2c4579606 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 02:22:26 +0900 Subject: [PATCH 044/570] docs(changelog): record resource budget error contract --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4bd39ca..fbb7e2e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Credential-free TLS evidence containing canonical origin, requested and observed peer, DNS/IP reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration. - Credential-free connection and redirect evidence containing canonical addresses, destination classes, target digests, hop numbers, and approved-address counts. - Credential-free verified TCP evidence containing the logical origin, requested socket, observed peer, destination class, successful attempt number, and per-attempt timeout. -- Standard `Display` and `std::error::Error` contracts for destination, redirect, digest, direct-network, and TLS failures, including preserved destination-policy, rustls, and operating-system sources where applicable. +- Standard `Display` and `std::error::Error` contracts for destination, redirect, digest, direct-network, TLS, and resource-budget failures, including preserved destination-policy, rustls, and operating-system sources where applicable. - Real loopback TCP integration proof plus deterministic timeout, refusal, retry, peer-inspection, peer-mismatch, canonicalization, IPv6 metadata, and single-use replay tests. - Real loopback rustls integration covering trusted DNS SAN, Common-Name fallback rejection, wrong-name and untrusted-root rejection, fixed-time expiry and not-yet-valid failures, exact IPv4 and IPv6 SANs, TLS 1.2/TLS 1.3, required and optional ALPN, and transport-origin binding. - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. From 12c25f9c77f7b44e65ab9855ada9f3a8ab834604 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 03:33:21 +0900 Subject: [PATCH 045/570] test(core): reject non-digit origin port prefixes --- .../tests/origin_port_syntax.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 crates/originweave-core/tests/origin_port_syntax.rs diff --git a/crates/originweave-core/tests/origin_port_syntax.rs b/crates/originweave-core/tests/origin_port_syntax.rs new file mode 100644 index 000000000..ce58e523e --- /dev/null +++ b/crates/originweave-core/tests/origin_port_syntax.rs @@ -0,0 +1,18 @@ +use originweave_core::{Origin, OriginError}; + +#[test] +fn origin_rejects_non_digit_port_prefixes() { + for input in [ + "https://example.com:+443", + "https://example.com:+8443", + "http://localhost:+80", + "http://127.0.0.1:+8080", + "https://[2001:db8::1]:+443", + ] { + assert_eq!( + Origin::parse(input), + Err(OriginError::InvalidPort), + "input={input}" + ); + } +} From 222d52f2a9bbee4ee8eebb17e79bab0a61d247f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 03:41:00 +0900 Subject: [PATCH 046/570] fix(core): enforce digit-only origin ports --- crates/originweave-core/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 88dd2e586..b7ab20379 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -165,6 +165,9 @@ fn parse_bracketed_ipv6(authority: &str) -> Result<(String, Option, bool), } fn parse_port(port_text: &str) -> Result { + if port_text.is_empty() || !port_text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(OriginError::InvalidPort); + } let port = port_text .parse::() .map_err(|_error| OriginError::InvalidPort)?; @@ -1062,4 +1065,4 @@ pub fn evaluate_extension_access( return ExtensionAccessDecision::DenyCapabilityNotGranted; } ExtensionAccessDecision::Allow -} +} \ No newline at end of file From 1232edad8d50fb56c4f028309945b3cee0c310fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:11:14 +0900 Subject: [PATCH 047/570] fix(core): restore canonical rustfmt newline --- crates/originweave-core/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index b7ab20379..632ca9490 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -422,7 +422,7 @@ pub enum NodeHandleError { StaleDocumentEpoch { /// Epoch that originally produced the node handle. observed: DocumentEpoch, - /// Epoch currently active in the browser context. + /// Epoch currently active for the requested action. current: DocumentEpoch, }, } @@ -1065,4 +1065,4 @@ pub fn evaluate_extension_access( return ExtensionAccessDecision::DenyCapabilityNotGranted; } ExtensionAccessDecision::Allow -} \ No newline at end of file +} From a6ad10cfaace873f600db85305a1a743d444e124 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:17:09 +0900 Subject: [PATCH 048/570] docs(changelog): record strict origin port syntax --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4bd39ca..a70b658cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - State-changing actions are same-origin by default. - R3 and R4 approvals are bound to the exact action, target origin, and immutable digest of the complete canonical action intent; R5 legal consent is non-delegable. - Shortened, integer, hexadecimal, and legacy octal-looking IPv4 host spellings are rejected so the policy origin cannot diverge from Chromium host interpretation. +- Explicit origin ports must contain ASCII decimal digits before numeric parsing, preventing Rust-only signed spellings such as a leading `+` from diverging from browser URL port syntax. - IPv4-mapped IPv6 is canonicalized before destination classification and pin comparison so mapped private or loopback addresses cannot bypass IPv4 policy. - The default destination policy permits only public addresses and denies unspecified, loopback, private, shared, link-local, metadata, documentation, benchmarking, multicast, broadcast, transition, and protocol-reserved destinations. - Azure platform IP `168.63.129.16` and Amazon EKS Pod Identity endpoints `169.254.170.23` and `fd00:ec2::23` are classified as metadata or platform services before broader public, link-local, or unique-local rules. From a9d432044c549c1926ee56143f1b3e8ca07e9ac3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:59:30 +0900 Subject: [PATCH 049/570] test(destination): reject non-digit proxy port prefixes --- .../tests/proxy_port_syntax.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 crates/originweave-destination/tests/proxy_port_syntax.rs diff --git a/crates/originweave-destination/tests/proxy_port_syntax.rs b/crates/originweave-destination/tests/proxy_port_syntax.rs new file mode 100644 index 000000000..7af1183f4 --- /dev/null +++ b/crates/originweave-destination/tests/proxy_port_syntax.rs @@ -0,0 +1,18 @@ +use originweave_destination::{ProxyServer, ProxyServerError}; + +#[test] +fn proxy_server_rejects_non_digit_port_prefixes() { + for input in [ + "proxy.example:+8080", + "http://proxy.example:+8080", + "https://proxy.example:+8443", + "socks5://proxy.example:+1080", + "https://[2001:db8::1]:+8443", + ] { + assert_eq!( + ProxyServer::parse(input), + Err(ProxyServerError::InvalidIdentifier), + "input={input}", + ); + } +} From d4795b4e1b5d77f8ed55a4fda9a7a7ff20f3086a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 05:02:39 +0900 Subject: [PATCH 050/570] fix(destination): require decimal proxy ports --- crates/originweave-destination/src/proxy.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/originweave-destination/src/proxy.rs b/crates/originweave-destination/src/proxy.rs index ef64289fa..4695dc3aa 100644 --- a/crates/originweave-destination/src/proxy.rs +++ b/crates/originweave-destination/src/proxy.rs @@ -446,6 +446,9 @@ fn explicit_port(authority: &str) -> Result, ProxyServerError> { port }; + if !port_text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(ProxyServerError::InvalidIdentifier); + } let port = port_text .parse::() .map_err(|_error| ProxyServerError::InvalidIdentifier)?; From c6edf75f8e5a4d822eac346eb9d24aeb27dadc16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 05:07:01 +0900 Subject: [PATCH 051/570] test(destination): cover out-of-range decimal proxy ports --- .../tests/proxy_port_syntax.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/originweave-destination/tests/proxy_port_syntax.rs b/crates/originweave-destination/tests/proxy_port_syntax.rs index 7af1183f4..9038c14ed 100644 --- a/crates/originweave-destination/tests/proxy_port_syntax.rs +++ b/crates/originweave-destination/tests/proxy_port_syntax.rs @@ -16,3 +16,14 @@ fn proxy_server_rejects_non_digit_port_prefixes() { ); } } + +#[test] +fn proxy_server_rejects_decimal_ports_outside_u16_range() { + for input in ["proxy.example:65536", "https://[2001:db8::1]:65536"] { + assert_eq!( + ProxyServer::parse(input), + Err(ProxyServerError::InvalidIdentifier), + "input={input}", + ); + } +} From adb09a40699a70b7c7e252cd88dec42d41833b1f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 05:09:05 +0900 Subject: [PATCH 052/570] docs(changelog): record strict proxy port syntax --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4bd39ca..48e463d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security +- Explicit proxy server identifiers require ASCII decimal port tokens before numeric range parsing, preventing Rust-specific leading-plus spellings from widening proxy authority. - Raw page content cannot become a trusted instruction. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. From a5621a4480eb9457bbf48075aa040dbbcb4675f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 06:57:43 +0900 Subject: [PATCH 053/570] test(tls): reject punctuation-only trust bundle identifiers --- crates/originweave-tls/tests/policy_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-tls/tests/policy_contract.rs b/crates/originweave-tls/tests/policy_contract.rs index 5a8b71ef4..4fad353b3 100644 --- a/crates/originweave-tls/tests/policy_contract.rs +++ b/crates/originweave-tls/tests/policy_contract.rs @@ -25,7 +25,7 @@ fn trust_bundle_identifier_is_bounded_and_ascii() { TrustBundleIdentifier::parse("enterprise_roots:v1").expect("valid trust bundle identifier"); assert_eq!(identifier.as_str(), "enterprise_roots:v1"); - for invalid in ["", "contains space", "한글", "slash/value"] { + for invalid in ["", "contains space", "한글", "slash/value", "---"] { assert!(matches!( TrustBundleIdentifier::parse(invalid), Err(TlsError::InvalidTrustBundleIdentifier) From 28062bba64b2ff9bc9982183fbba9cb00f8ea9c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 07:05:49 +0900 Subject: [PATCH 054/570] fix(tls): require meaningful trust bundle identifiers --- crates/originweave-tls/src/trust.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/originweave-tls/src/trust.rs b/crates/originweave-tls/src/trust.rs index f3e3374b6..32aa66e17 100644 --- a/crates/originweave-tls/src/trust.rs +++ b/crates/originweave-tls/src/trust.rs @@ -19,6 +19,7 @@ impl TrustBundleIdentifier { pub fn parse(input: &str) -> Result { if input.is_empty() || input.len() > 128 + || !input.bytes().any(|byte| byte.is_ascii_alphanumeric()) || !input.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-') }) From bd34717585546c6989d10ff6c2be3dc1232ad638 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 14:14:47 +0900 Subject: [PATCH 055/570] docs(tls): record trust bundle identifier hardening --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4bd39ca..dc62e0b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - TLS accepts only an already verified direct stream, never a hostname or new socket, and requires the TLS origin to match the transport-authority origin exactly. - 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. - 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. From a119190918999858c66f43bc3f613f05e072b544 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 16:56:59 +0900 Subject: [PATCH 056/570] chore(core): remove unrelated node-handle doc drift --- crates/originweave-core/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 632ca9490..36bada998 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -422,7 +422,7 @@ pub enum NodeHandleError { StaleDocumentEpoch { /// Epoch that originally produced the node handle. observed: DocumentEpoch, - /// Epoch currently active for the requested action. + /// Epoch currently active in the browser context. current: DocumentEpoch, }, } From 240a66d991ba2d68c2ba6a900dd06951b25c68e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 17:09:54 +0900 Subject: [PATCH 057/570] docs(core): cite browser port syntax authority --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a70b658cc..f705f96fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - State-changing actions are same-origin by default. - R3 and R4 approvals are bound to the exact action, target origin, and immutable digest of the complete canonical action intent; R5 legal consent is non-delegable. - Shortened, integer, hexadecimal, and legacy octal-looking IPv4 host spellings are rejected so the policy origin cannot diverge from Chromium host interpretation. -- Explicit origin ports must contain ASCII decimal digits before numeric parsing, preventing Rust-only signed spellings such as a leading `+` from diverging from browser URL port syntax. +- Explicit origin ports must contain ASCII decimal digits before numeric parsing, matching the WHATWG URL Standard port-state syntax and preventing Rust-only signed spellings such as a leading `+` from creating a browser/parser authority mismatch. - IPv4-mapped IPv6 is canonicalized before destination classification and pin comparison so mapped private or loopback addresses cannot bypass IPv4 policy. - The default destination policy permits only public addresses and denies unspecified, loopback, private, shared, link-local, metadata, documentation, benchmarking, multicast, broadcast, transition, and protocol-reserved destinations. - Azure platform IP `168.63.129.16` and Amazon EKS Pod Identity endpoints `169.254.170.23` and `fd00:ec2::23` are classified as metadata or platform services before broader public, link-local, or unique-local rules. @@ -74,4 +74,8 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. +### References + +Web Hypertext Application Technology Working Group. (2026). *URL standard*. https://url.spec.whatwg.org/ + [Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD From b15fcbbb35f8e3230841214b12beeb2d75d7a80b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:39:31 +0900 Subject: [PATCH 058/570] test(tls): require target-independent bundle hash framing --- crates/originweave-tls/src/trust.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/originweave-tls/src/trust.rs b/crates/originweave-tls/src/trust.rs index 32aa66e17..0bc3a0ce7 100644 --- a/crates/originweave-tls/src/trust.rs +++ b/crates/originweave-tls/src/trust.rs @@ -146,3 +146,17 @@ pub(crate) fn sha256_identifier(bytes: &[u8]) -> String { } identifier } + +#[cfg(test)] +mod tests { + use super::canonical_u64_bytes; + + #[test] + fn trust_bundle_hash_lengths_use_fixed_eight_byte_encoding() { + assert_eq!(canonical_u64_bytes(1), [0, 0, 0, 0, 0, 0, 0, 1]); + assert_eq!( + canonical_u64_bytes(0x0102_0304), + [0, 0, 0, 0, 1, 2, 3, 4] + ); + } +} From 04557ad1f29dad75d065efe481a7acce50e73bdb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:03:07 +0900 Subject: [PATCH 059/570] test(mcp): require typed stateless tool routing boundary --- .../tests/mcp_authority_route.rs | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 crates/originweave-core/tests/mcp_authority_route.rs diff --git a/crates/originweave-core/tests/mcp_authority_route.rs b/crates/originweave-core/tests/mcp_authority_route.rs new file mode 100644 index 000000000..3a5efba9c --- /dev/null +++ b/crates/originweave-core/tests/mcp_authority_route.rs @@ -0,0 +1,135 @@ +use std::error::Error; + +use originweave_core::ActionKind; +use originweave_core::mcp::{ + MAX_MCP_TOOL_NAME_BYTES, MCP_PROTOCOL_VERSION, MCP_TOOLS_CALL_METHOD, McpToolBoundaryError, + ValidatedMcpToolCall, +}; + +fn validate(tool_name: &str) -> Result { + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + tool_name, + MCP_TOOLS_CALL_METHOD, + tool_name, + ) +} + +#[test] +fn supported_mcp_tools_map_to_exact_originweave_actions() -> Result<(), Box> { + let cases = [ + ("originweave.observe", ActionKind::Observe), + ("originweave.extract", ActionKind::Extract), + ("originweave.navigate", ActionKind::Navigate), + ("originweave.download", ActionKind::Download), + ("originweave.draft", ActionKind::Draft), + ("originweave.submit", ActionKind::Submit), + ("originweave.upload", ActionKind::Upload), + ("originweave.fill_secret", ActionKind::FillSecret), + ("originweave.purchase", ActionKind::Purchase), + ("originweave.delete", ActionKind::Delete), + ("originweave.manage_permission", ActionKind::ManagePermission), + ]; + + for (tool_name, expected_action) in cases { + let call = validate(tool_name)?; + assert_eq!(call.tool_name(), tool_name); + assert_eq!(call.action_kind(), expected_action); + assert_eq!(call.action_kind().required_capability(), expected_action.required_capability()); + assert_eq!(call.action_kind().risk_class(), expected_action.risk_class()); + } + Ok(()) +} + +#[test] +fn mcp_route_rejects_protocol_header_body_and_method_drift() { + assert_eq!( + ValidatedMcpToolCall::new( + "2025-11-25", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::UnsupportedProtocolVersion) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + "tools/list", + "originweave.observe", + ), + Err(McpToolBoundaryError::HeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.extract", + ), + Err(McpToolBoundaryError::HeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + "resources/read", + "originweave.observe", + "resources/read", + "originweave.observe", + ), + Err(McpToolBoundaryError::UnsupportedMethod) + ); +} + +#[test] +fn mcp_route_rejects_unbounded_malformed_and_unmapped_tool_names() { + let oversized = "x".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); + for tool_name in ["", "originweave legal", "originweave/observe", "originweave.관찰", &oversized] { + assert_eq!(validate(tool_name), Err(McpToolBoundaryError::InvalidToolName)); + } + + assert_eq!( + validate("originweave.legal_consent"), + Err(McpToolBoundaryError::UnknownTool) + ); + assert_eq!( + validate("third_party.arbitrary_javascript"), + Err(McpToolBoundaryError::UnknownTool) + ); +} + +#[test] +fn mcp_boundary_errors_are_deterministic_and_do_not_echo_untrusted_values() { + let cases = [ + ( + McpToolBoundaryError::UnsupportedProtocolVersion, + "unsupported MCP protocol version", + ), + ( + McpToolBoundaryError::HeaderBodyMismatch, + "MCP routing headers do not match the request body", + ), + ( + McpToolBoundaryError::UnsupportedMethod, + "only MCP tools/call requests can enter the typed action boundary", + ), + ( + McpToolBoundaryError::InvalidToolName, + "MCP tool name violates the bounded ASCII routing syntax", + ), + ( + McpToolBoundaryError::UnknownTool, + "MCP tool is not mapped to an OriginWeave typed action", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + assert!(error.source().is_none()); + } +} From 34311626859ec335af003c75d2de3aed959278a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:05:44 +0900 Subject: [PATCH 060/570] style(mcp): apply canonical rustfmt to red contract --- .../tests/mcp_authority_route.rs | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/tests/mcp_authority_route.rs b/crates/originweave-core/tests/mcp_authority_route.rs index 3a5efba9c..5bda207e7 100644 --- a/crates/originweave-core/tests/mcp_authority_route.rs +++ b/crates/originweave-core/tests/mcp_authority_route.rs @@ -29,15 +29,24 @@ fn supported_mcp_tools_map_to_exact_originweave_actions() -> Result<(), Box Date: Sun, 16 Aug 2026 04:11:37 +0900 Subject: [PATCH 061/570] feat(mcp): enforce typed stateless routing boundary --- crates/originweave-core/Cargo.toml | 3 + crates/originweave-core/src/mcp.rs | 143 ++++++++++++++++++++++++++++ crates/originweave-core/src/root.rs | 15 +++ 3 files changed, 161 insertions(+) create mode 100644 crates/originweave-core/src/mcp.rs create mode 100644 crates/originweave-core/src/root.rs diff --git a/crates/originweave-core/Cargo.toml b/crates/originweave-core/Cargo.toml index 35c83b19b..517e41217 100644 --- a/crates/originweave-core/Cargo.toml +++ b/crates/originweave-core/Cargo.toml @@ -10,6 +10,9 @@ repository.workspace = true homepage.workspace = true publish = false +[lib] +path = "src/root.rs" + [dependencies] [lints] diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs new file mode 100644 index 000000000..d98cc1323 --- /dev/null +++ b/crates/originweave-core/src/mcp.rs @@ -0,0 +1,143 @@ +//! Fail-closed MCP routing integrity for the external adapter boundary. +//! +//! This module validates only the stateless MCP protocol/method/tool routing +//! envelope and derives an existing [`ActionKind`]. It is deliberately not an +//! authorization decision: callers must independently enforce OriginWeave +//! capability, risk, approval, origin, secret-broker, and evidence policies. +//! No MCP arguments, outputs, credentials, or arbitrary model-visible values +//! are retained by this boundary. + +use std::fmt; + +use crate::ActionKind; + +/// MCP protocol generation accepted by this stateless adapter boundary. +pub const MCP_PROTOCOL_VERSION: &str = "2026-07-28"; + +/// The only MCP method that can enter the typed action-routing boundary. +pub const MCP_TOOLS_CALL_METHOD: &str = "tools/call"; + +/// Maximum accepted MCP tool-name length in bytes. +pub const MAX_MCP_TOOL_NAME_BYTES: usize = 128; + +/// A deterministic failure while validating untrusted MCP routing metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpToolBoundaryError { + /// The request names an MCP protocol generation this boundary does not support. + UnsupportedProtocolVersion, + /// MCP routing metadata disagrees with the method or tool name in the body. + HeaderBodyMismatch, + /// The request method is not the supported `tools/call` operation. + UnsupportedMethod, + /// The tool name violates the bounded ASCII MCP routing syntax. + InvalidToolName, + /// The tool name has no explicit mapping to an OriginWeave typed action. + UnknownTool, +} + +impl fmt::Display for McpToolBoundaryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedProtocolVersion => { + formatter.write_str("unsupported MCP protocol version") + } + Self::HeaderBodyMismatch => { + formatter.write_str("MCP routing headers do not match the request body") + } + Self::UnsupportedMethod => formatter + .write_str("only MCP tools/call requests can enter the typed action boundary"), + Self::InvalidToolName => { + formatter.write_str("MCP tool name violates the bounded ASCII routing syntax") + } + Self::UnknownTool => { + formatter.write_str("MCP tool is not mapped to an OriginWeave typed action") + } + } + } +} + +impl std::error::Error for McpToolBoundaryError {} + +/// An MCP tool call whose routing envelope has been validated and mapped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ValidatedMcpToolCall { + tool_name: &'static str, + action_kind: ActionKind, +} + +impl ValidatedMcpToolCall { + /// Validate one stateless MCP tool-call routing envelope. + /// + /// Routing integrity is intentionally narrower than authorization. A + /// successful value proves only that the untrusted protocol version, + /// routing metadata, body method, and body tool name agree with one + /// explicitly supported mapping. + pub fn new( + protocol_version: &str, + routing_method: &str, + routing_tool_name: &str, + body_method: &str, + body_tool_name: &str, + ) -> Result { + if protocol_version != MCP_PROTOCOL_VERSION { + return Err(McpToolBoundaryError::UnsupportedProtocolVersion); + } + if routing_method != body_method || routing_tool_name != body_tool_name { + return Err(McpToolBoundaryError::HeaderBodyMismatch); + } + if routing_method != MCP_TOOLS_CALL_METHOD { + return Err(McpToolBoundaryError::UnsupportedMethod); + } + if !valid_tool_name(routing_tool_name) { + return Err(McpToolBoundaryError::InvalidToolName); + } + + let (tool_name, action_kind) = map_tool(routing_tool_name)?; + Ok(Self { + tool_name, + action_kind, + }) + } + + /// Return the canonical static tool name selected by the explicit mapping. + #[must_use] + pub const fn tool_name(&self) -> &'static str { + self.tool_name + } + + /// Return the existing OriginWeave typed action selected by this tool. + #[must_use] + pub const fn action_kind(&self) -> ActionKind { + self.action_kind + } +} + +fn valid_tool_name(tool_name: &str) -> bool { + if tool_name.is_empty() || tool_name.len() > MAX_MCP_TOOL_NAME_BYTES { + return false; + } + tool_name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.') + }) +} + +fn map_tool(tool_name: &str) -> Result<(&'static str, ActionKind), McpToolBoundaryError> { + let mapped = match tool_name { + "originweave.observe" => ("originweave.observe", ActionKind::Observe), + "originweave.extract" => ("originweave.extract", ActionKind::Extract), + "originweave.navigate" => ("originweave.navigate", ActionKind::Navigate), + "originweave.download" => ("originweave.download", ActionKind::Download), + "originweave.draft" => ("originweave.draft", ActionKind::Draft), + "originweave.submit" => ("originweave.submit", ActionKind::Submit), + "originweave.upload" => ("originweave.upload", ActionKind::Upload), + "originweave.fill_secret" => ("originweave.fill_secret", ActionKind::FillSecret), + "originweave.purchase" => ("originweave.purchase", ActionKind::Purchase), + "originweave.delete" => ("originweave.delete", ActionKind::Delete), + "originweave.manage_permission" => ( + "originweave.manage_permission", + ActionKind::ManagePermission, + ), + _ => return Err(McpToolBoundaryError::UnknownTool), + }; + Ok(mapped) +} diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs new file mode 100644 index 000000000..7acced460 --- /dev/null +++ b/crates/originweave-core/src/root.rs @@ -0,0 +1,15 @@ +//! Shared security and governance contracts for OriginWeave. +//! +//! The historical core contracts remain source-compatible while adapter-specific +//! boundaries can live in focused modules without changing their authority model. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +#[path = "lib.rs"] +mod contracts; + +pub use contracts::*; + +/// Stateless MCP routing validation that maps only explicit tools to typed actions. +pub mod mcp; From af308023b0d530dd7625cce764f936a01d37ca81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 04:13:46 +0900 Subject: [PATCH 062/570] style(mcp): apply canonical production rustfmt --- crates/originweave-core/src/mcp.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index d98cc1323..a83facb88 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -116,9 +116,9 @@ fn valid_tool_name(tool_name: &str) -> bool { if tool_name.is_empty() || tool_name.len() > MAX_MCP_TOOL_NAME_BYTES { return false; } - tool_name.bytes().all(|byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.') - }) + tool_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) } fn map_tool(tool_name: &str) -> Result<(&'static str, ActionKind), McpToolBoundaryError> { From 3ac6855a47cbbb5a10c0597798295b53fd52d529 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:34:26 +0900 Subject: [PATCH 063/570] test(mcp): pin routing policy boundaries --- .../tests/mcp_authority_route.rs | 91 +++++++++++++++---- 1 file changed, 71 insertions(+), 20 deletions(-) diff --git a/crates/originweave-core/tests/mcp_authority_route.rs b/crates/originweave-core/tests/mcp_authority_route.rs index 5bda207e7..bb4641234 100644 --- a/crates/originweave-core/tests/mcp_authority_route.rs +++ b/crates/originweave-core/tests/mcp_authority_route.rs @@ -1,10 +1,10 @@ use std::error::Error; -use originweave_core::ActionKind; use originweave_core::mcp::{ MAX_MCP_TOOL_NAME_BYTES, MCP_PROTOCOL_VERSION, MCP_TOOLS_CALL_METHOD, McpToolBoundaryError, ValidatedMcpToolCall, }; +use originweave_core::{ActionKind, Capability, RiskClass}; fn validate(tool_name: &str) -> Result { ValidatedMcpToolCall::new( @@ -19,34 +19,80 @@ fn validate(tool_name: &str) -> Result Result<(), Box> { let cases = [ - ("originweave.observe", ActionKind::Observe), - ("originweave.extract", ActionKind::Extract), - ("originweave.navigate", ActionKind::Navigate), - ("originweave.download", ActionKind::Download), - ("originweave.draft", ActionKind::Draft), - ("originweave.submit", ActionKind::Submit), - ("originweave.upload", ActionKind::Upload), - ("originweave.fill_secret", ActionKind::FillSecret), - ("originweave.purchase", ActionKind::Purchase), - ("originweave.delete", ActionKind::Delete), + ( + "originweave.observe", + ActionKind::Observe, + Capability::Observe, + RiskClass::R0, + ), + ( + "originweave.extract", + ActionKind::Extract, + Capability::Extract, + RiskClass::R0, + ), + ( + "originweave.navigate", + ActionKind::Navigate, + Capability::Navigate, + RiskClass::R1, + ), + ( + "originweave.download", + ActionKind::Download, + Capability::Download, + RiskClass::R1, + ), + ( + "originweave.draft", + ActionKind::Draft, + Capability::Draft, + RiskClass::R2, + ), + ( + "originweave.submit", + ActionKind::Submit, + Capability::Submit, + RiskClass::R3, + ), + ( + "originweave.upload", + ActionKind::Upload, + Capability::Upload, + RiskClass::R3, + ), + ( + "originweave.fill_secret", + ActionKind::FillSecret, + Capability::FillSecret, + RiskClass::R3, + ), + ( + "originweave.purchase", + ActionKind::Purchase, + Capability::Purchase, + RiskClass::R4, + ), + ( + "originweave.delete", + ActionKind::Delete, + Capability::Delete, + RiskClass::R4, + ), ( "originweave.manage_permission", ActionKind::ManagePermission, + Capability::ManagePermission, + RiskClass::R4, ), ]; - for (tool_name, expected_action) in cases { + for (tool_name, expected_action, expected_capability, expected_risk) in cases { let call = validate(tool_name)?; assert_eq!(call.tool_name(), tool_name); assert_eq!(call.action_kind(), expected_action); - assert_eq!( - call.action_kind().required_capability(), - expected_action.required_capability() - ); - assert_eq!( - call.action_kind().risk_class(), - expected_action.risk_class() - ); + assert_eq!(call.action_kind().required_capability(), expected_capability); + assert_eq!(call.action_kind().risk_class(), expected_risk); } Ok(()) } @@ -97,6 +143,7 @@ fn mcp_route_rejects_protocol_header_body_and_method_drift() { #[test] fn mcp_route_rejects_unbounded_malformed_and_unmapped_tool_names() { + let at_limit = "x".repeat(MAX_MCP_TOOL_NAME_BYTES); let oversized = "x".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); for tool_name in [ "", @@ -111,6 +158,10 @@ fn mcp_route_rejects_unbounded_malformed_and_unmapped_tool_names() { ); } + assert_eq!( + validate(&at_limit), + Err(McpToolBoundaryError::UnknownTool) + ); assert_eq!( validate("originweave.legal_consent"), Err(McpToolBoundaryError::UnknownTool) From bfed566e556f2c694ed639cbdebbf9e733c37a45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:36:06 +0900 Subject: [PATCH 064/570] refactor(mcp): centralize explicit tool mapping --- crates/originweave-core/src/mcp.rs | 41 +++++++++++++++++------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index a83facb88..297d050c1 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -20,6 +20,24 @@ pub const MCP_TOOLS_CALL_METHOD: &str = "tools/call"; /// Maximum accepted MCP tool-name length in bytes. pub const MAX_MCP_TOOL_NAME_BYTES: usize = 128; +/// The complete explicit MCP tool-to-action mapping accepted by this boundary. +const MCP_TOOL_ACTION_MAP: &[(&str, ActionKind)] = &[ + ("originweave.observe", ActionKind::Observe), + ("originweave.extract", ActionKind::Extract), + ("originweave.navigate", ActionKind::Navigate), + ("originweave.download", ActionKind::Download), + ("originweave.draft", ActionKind::Draft), + ("originweave.submit", ActionKind::Submit), + ("originweave.upload", ActionKind::Upload), + ("originweave.fill_secret", ActionKind::FillSecret), + ("originweave.purchase", ActionKind::Purchase), + ("originweave.delete", ActionKind::Delete), + ( + "originweave.manage_permission", + ActionKind::ManagePermission, + ), +]; + /// A deterministic failure while validating untrusted MCP routing metadata. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpToolBoundaryError { @@ -122,22 +140,9 @@ fn valid_tool_name(tool_name: &str) -> bool { } fn map_tool(tool_name: &str) -> Result<(&'static str, ActionKind), McpToolBoundaryError> { - let mapped = match tool_name { - "originweave.observe" => ("originweave.observe", ActionKind::Observe), - "originweave.extract" => ("originweave.extract", ActionKind::Extract), - "originweave.navigate" => ("originweave.navigate", ActionKind::Navigate), - "originweave.download" => ("originweave.download", ActionKind::Download), - "originweave.draft" => ("originweave.draft", ActionKind::Draft), - "originweave.submit" => ("originweave.submit", ActionKind::Submit), - "originweave.upload" => ("originweave.upload", ActionKind::Upload), - "originweave.fill_secret" => ("originweave.fill_secret", ActionKind::FillSecret), - "originweave.purchase" => ("originweave.purchase", ActionKind::Purchase), - "originweave.delete" => ("originweave.delete", ActionKind::Delete), - "originweave.manage_permission" => ( - "originweave.manage_permission", - ActionKind::ManagePermission, - ), - _ => return Err(McpToolBoundaryError::UnknownTool), - }; - Ok(mapped) + MCP_TOOL_ACTION_MAP + .iter() + .copied() + .find(|(mapped_name, _)| *mapped_name == tool_name) + .ok_or(McpToolBoundaryError::UnknownTool) } From e5ac16d272225268fc913eded30dbc62c8796065 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:40:17 +0900 Subject: [PATCH 065/570] style(mcp): apply canonical rustfmt diagnostics --- crates/originweave-core/tests/mcp_authority_route.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/tests/mcp_authority_route.rs b/crates/originweave-core/tests/mcp_authority_route.rs index bb4641234..c5fc81990 100644 --- a/crates/originweave-core/tests/mcp_authority_route.rs +++ b/crates/originweave-core/tests/mcp_authority_route.rs @@ -91,7 +91,10 @@ fn supported_mcp_tools_map_to_exact_originweave_actions() -> Result<(), Box Date: Sun, 16 Aug 2026 09:01:43 +0900 Subject: [PATCH 066/570] test(mcp): bind routed tool identity to policy action --- .../tests/mcp_route_binding.rs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 crates/originweave-policy/tests/mcp_route_binding.rs diff --git a/crates/originweave-policy/tests/mcp_route_binding.rs b/crates/originweave-policy/tests/mcp_route_binding.rs new file mode 100644 index 000000000..4079471d7 --- /dev/null +++ b/crates/originweave-policy/tests/mcp_route_binding.rs @@ -0,0 +1,98 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::mcp::{ + MCP_PROTOCOL_VERSION, MCP_TOOLS_CALL_METHOD, ValidatedMcpToolCall, +}; +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, Capability, ExecutionPurpose, + InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, +}; +use originweave_policy::{Decision, DenialReason, evaluate_mcp}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn origin() -> Origin { + Origin::parse("https://mcp.example").expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn validated_call(tool_name: &str) -> ValidatedMcpToolCall { + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + tool_name, + MCP_TOOLS_CALL_METHOD, + tool_name, + ) + .expect("known test MCP tool") +} + +fn request(action: ActionKind) -> ActionRequest { + let site = origin(); + ActionRequest::new( + action, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ) +} + +fn context(capabilities: BTreeSet) -> PolicyContext { + let site = origin(); + PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + capabilities, + BTreeSet::from([site.clone()]), + BTreeSet::from([site]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ) +} + +#[test] +fn matching_mcp_route_enters_the_existing_policy_boundary() { + let call = validated_call("originweave.observe"); + let decision = evaluate_mcp( + &call, + &request(ActionKind::Observe), + &context(BTreeSet::from([Capability::Observe])), + ); + + assert_eq!(decision, Decision::Allow); +} + +#[test] +fn mismatched_mcp_route_cannot_be_reinterpreted_as_another_action() { + let call = validated_call("originweave.observe"); + let decision = evaluate_mcp( + &call, + &request(ActionKind::Navigate), + &context(BTreeSet::from([Capability::Navigate])), + ); + + assert_eq!(decision, Decision::Deny(DenialReason::McpActionMismatch)); +} + +#[test] +fn matching_mcp_route_does_not_bypass_existing_policy_denials() { + let call = validated_call("originweave.navigate"); + let decision = evaluate_mcp( + &call, + &request(ActionKind::Navigate), + &context(BTreeSet::from([Capability::Observe])), + ); + + assert_eq!( + decision, + Decision::Deny(DenialReason::MissingCapability(Capability::Navigate)) + ); +} From 0808e531fb6546740983cb91e9c28f10293caae5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 09:05:10 +0900 Subject: [PATCH 067/570] feat(mcp): fail closed on route-action mismatch --- crates/originweave-policy/src/lib.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index 243ae8ce7..dbfb3c16d 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -15,6 +15,7 @@ pub use sensitive_data::{ evaluate_handle_use, }; +use originweave_core::mcp::ValidatedMcpToolCall; use originweave_core::{ ActionRequest, ApprovalEvidence, ApprovalScope, Capability, ExecutionPurpose, InstructionSource, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, SessionMode, @@ -40,6 +41,8 @@ pub enum DenialReason { ModePurposeMismatch, /// Page or document content attempted to become a trusted instruction. UntrustedInstructionSource, + /// The validated MCP route resolved to a different action than the policy request. + McpActionMismatch, /// The session lacks the exact capability required by the action. MissingCapability(Capability), /// The target origin is outside the session's read grant. @@ -66,6 +69,23 @@ pub enum DenialReason { ApprovalScopeMismatch, } +/// Evaluate a policy request only when it matches an already validated MCP route. +/// +/// Matching routing metadata grants no authority. Once route and request action agree, the request +/// still passes through the existing action policy unchanged. +#[must_use] +pub fn evaluate_mcp( + call: &ValidatedMcpToolCall, + request: &ActionRequest, + context: &PolicyContext, +) -> Decision { + if call.action_kind() != request.action() { + return Decision::Deny(DenialReason::McpActionMismatch); + } + + evaluate(request, context) +} + /// Evaluate a typed browser action against one explicit policy context. #[must_use] pub fn evaluate(request: &ActionRequest, context: &PolicyContext) -> Decision { From 50c80abe81cf8b68d2f0f20ede9d7a24aa3f60ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 09:08:28 +0900 Subject: [PATCH 068/570] style(mcp): apply canonical rustfmt diagnostics --- crates/originweave-policy/tests/mcp_route_binding.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/originweave-policy/tests/mcp_route_binding.rs b/crates/originweave-policy/tests/mcp_route_binding.rs index 4079471d7..8e9661af6 100644 --- a/crates/originweave-policy/tests/mcp_route_binding.rs +++ b/crates/originweave-policy/tests/mcp_route_binding.rs @@ -2,9 +2,7 @@ use std::collections::BTreeSet; -use originweave_core::mcp::{ - MCP_PROTOCOL_VERSION, MCP_TOOLS_CALL_METHOD, ValidatedMcpToolCall, -}; +use originweave_core::mcp::{MCP_PROTOCOL_VERSION, MCP_TOOLS_CALL_METHOD, ValidatedMcpToolCall}; use originweave_core::{ ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, Capability, ExecutionPurpose, InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, From 169814e1327de370f2ac234ea745fdc2ffb40a92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:03:58 +0900 Subject: [PATCH 069/570] test(mcp): require deterministic tool catalog --- .../tests/mcp_authority_route.rs | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/mcp_authority_route.rs b/crates/originweave-core/tests/mcp_authority_route.rs index c5fc81990..8662a89f9 100644 --- a/crates/originweave-core/tests/mcp_authority_route.rs +++ b/crates/originweave-core/tests/mcp_authority_route.rs @@ -2,7 +2,7 @@ use std::error::Error; use originweave_core::mcp::{ MAX_MCP_TOOL_NAME_BYTES, MCP_PROTOCOL_VERSION, MCP_TOOLS_CALL_METHOD, McpToolBoundaryError, - ValidatedMcpToolCall, + ValidatedMcpToolCall, supported_mcp_tools, }; use originweave_core::{ActionKind, Capability, RiskClass}; @@ -100,6 +100,51 @@ fn supported_mcp_tools_map_to_exact_originweave_actions() -> Result<(), Box Result<(), Box> { + let expected = [ + ("originweave.observe", ActionKind::Observe), + ("originweave.extract", ActionKind::Extract), + ("originweave.navigate", ActionKind::Navigate), + ("originweave.download", ActionKind::Download), + ("originweave.draft", ActionKind::Draft), + ("originweave.submit", ActionKind::Submit), + ("originweave.upload", ActionKind::Upload), + ("originweave.fill_secret", ActionKind::FillSecret), + ("originweave.purchase", ActionKind::Purchase), + ("originweave.delete", ActionKind::Delete), + ("originweave.manage_permission", ActionKind::ManagePermission), + ]; + let catalog = supported_mcp_tools(); + + assert_eq!(catalog.len(), expected.len()); + for (entry, (expected_name, expected_action)) in catalog.iter().zip(expected) { + assert_eq!(entry.tool_name(), expected_name); + assert_eq!(entry.action_kind(), expected_action); + assert_eq!( + entry.required_capability(), + expected_action.required_capability() + ); + assert_eq!(entry.risk_class(), expected_action.risk_class()); + + let call = validate(entry.tool_name())?; + assert_eq!(call.action_kind(), entry.action_kind()); + } + + for (index, entry) in catalog.iter().enumerate() { + for other in &catalog[index + 1..] { + assert_ne!(entry.tool_name(), other.tool_name()); + assert_ne!(entry.action_kind(), other.action_kind()); + } + } + assert!( + catalog + .iter() + .all(|entry| entry.action_kind() != ActionKind::LegalConsent) + ); + Ok(()) +} + #[test] fn mcp_route_rejects_protocol_header_body_and_method_drift() { assert_eq!( From b0a0b307833a3b8b867461a9cdd935afb952185a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:05:48 +0900 Subject: [PATCH 070/570] test(mcp): apply canonical catalog formatting --- crates/originweave-core/tests/mcp_authority_route.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/mcp_authority_route.rs b/crates/originweave-core/tests/mcp_authority_route.rs index 8662a89f9..df359ccf0 100644 --- a/crates/originweave-core/tests/mcp_authority_route.rs +++ b/crates/originweave-core/tests/mcp_authority_route.rs @@ -101,7 +101,8 @@ fn supported_mcp_tools_map_to_exact_originweave_actions() -> Result<(), Box Result<(), Box> { +fn mcp_tool_catalog_is_deterministic_complete_and_action_unambiguous() -> Result<(), Box> +{ let expected = [ ("originweave.observe", ActionKind::Observe), ("originweave.extract", ActionKind::Extract), @@ -113,7 +114,10 @@ fn mcp_tool_catalog_is_deterministic_complete_and_action_unambiguous() -> Result ("originweave.fill_secret", ActionKind::FillSecret), ("originweave.purchase", ActionKind::Purchase), ("originweave.delete", ActionKind::Delete), - ("originweave.manage_permission", ActionKind::ManagePermission), + ( + "originweave.manage_permission", + ActionKind::ManagePermission, + ), ]; let catalog = supported_mcp_tools(); From 23a17b556ffafeec59df56657f60c5309ad2b963 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:07:22 +0900 Subject: [PATCH 071/570] feat(mcp): expose deterministic reviewed tool catalog --- crates/originweave-core/src/mcp.rs | 118 ++++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 20 deletions(-) diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index 297d050c1..c3ef61e15 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -9,7 +9,7 @@ use std::fmt; -use crate::ActionKind; +use crate::{ActionKind, Capability, RiskClass}; /// MCP protocol generation accepted by this stateless adapter boundary. pub const MCP_PROTOCOL_VERSION: &str = "2026-07-28"; @@ -20,24 +20,102 @@ pub const MCP_TOOLS_CALL_METHOD: &str = "tools/call"; /// Maximum accepted MCP tool-name length in bytes. pub const MAX_MCP_TOOL_NAME_BYTES: usize = 128; -/// The complete explicit MCP tool-to-action mapping accepted by this boundary. -const MCP_TOOL_ACTION_MAP: &[(&str, ActionKind)] = &[ - ("originweave.observe", ActionKind::Observe), - ("originweave.extract", ActionKind::Extract), - ("originweave.navigate", ActionKind::Navigate), - ("originweave.download", ActionKind::Download), - ("originweave.draft", ActionKind::Draft), - ("originweave.submit", ActionKind::Submit), - ("originweave.upload", ActionKind::Upload), - ("originweave.fill_secret", ActionKind::FillSecret), - ("originweave.purchase", ActionKind::Purchase), - ("originweave.delete", ActionKind::Delete), - ( - "originweave.manage_permission", - ActionKind::ManagePermission, - ), +/// One deterministic MCP tool descriptor derived from OriginWeave's reviewed action registry. +/// +/// The descriptor is discovery metadata only. It does not grant capabilities, origin access, +/// approval, secret access, or any other authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct McpToolCatalogEntry { + tool_name: &'static str, + action_kind: ActionKind, +} + +impl McpToolCatalogEntry { + /// Return the canonical MCP tool name exposed by this registry entry. + #[must_use] + pub const fn tool_name(&self) -> &'static str { + self.tool_name + } + + /// Return the typed OriginWeave action represented by this registry entry. + #[must_use] + pub const fn action_kind(&self) -> ActionKind { + self.action_kind + } + + /// Return the capability required by the represented action. + #[must_use] + pub const fn required_capability(&self) -> Capability { + self.action_kind.required_capability() + } + + /// Return the risk class assigned to the represented action. + #[must_use] + pub const fn risk_class(&self) -> RiskClass { + self.action_kind.risk_class() + } +} + +/// The complete explicit MCP tool-to-action registry accepted by this boundary. +/// +/// Order is deterministic so adapters can derive stable discovery output from this single +/// reviewed registry rather than maintaining a second mapping that could drift from routing. +const MCP_TOOL_CATALOG: &[McpToolCatalogEntry] = &[ + McpToolCatalogEntry { + tool_name: "originweave.observe", + action_kind: ActionKind::Observe, + }, + McpToolCatalogEntry { + tool_name: "originweave.extract", + action_kind: ActionKind::Extract, + }, + McpToolCatalogEntry { + tool_name: "originweave.navigate", + action_kind: ActionKind::Navigate, + }, + McpToolCatalogEntry { + tool_name: "originweave.download", + action_kind: ActionKind::Download, + }, + McpToolCatalogEntry { + tool_name: "originweave.draft", + action_kind: ActionKind::Draft, + }, + McpToolCatalogEntry { + tool_name: "originweave.submit", + action_kind: ActionKind::Submit, + }, + McpToolCatalogEntry { + tool_name: "originweave.upload", + action_kind: ActionKind::Upload, + }, + McpToolCatalogEntry { + tool_name: "originweave.fill_secret", + action_kind: ActionKind::FillSecret, + }, + McpToolCatalogEntry { + tool_name: "originweave.purchase", + action_kind: ActionKind::Purchase, + }, + McpToolCatalogEntry { + tool_name: "originweave.delete", + action_kind: ActionKind::Delete, + }, + McpToolCatalogEntry { + tool_name: "originweave.manage_permission", + action_kind: ActionKind::ManagePermission, + }, ]; +/// Return the deterministic reviewed MCP tool catalog. +/// +/// Adapters may use this slice to derive discovery responses. Serialization, pagination, cache +/// policy, transport I/O, and authorization remain outside this stateless registry boundary. +#[must_use] +pub const fn supported_mcp_tools() -> &'static [McpToolCatalogEntry] { + MCP_TOOL_CATALOG +} + /// A deterministic failure while validating untrusted MCP routing metadata. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpToolBoundaryError { @@ -140,9 +218,9 @@ fn valid_tool_name(tool_name: &str) -> bool { } fn map_tool(tool_name: &str) -> Result<(&'static str, ActionKind), McpToolBoundaryError> { - MCP_TOOL_ACTION_MAP + MCP_TOOL_CATALOG .iter() - .copied() - .find(|(mapped_name, _)| *mapped_name == tool_name) + .find(|entry| entry.tool_name == tool_name) + .map(|entry| (entry.tool_name, entry.action_kind)) .ok_or(McpToolBoundaryError::UnknownTool) } From 9520ffeca808bf75a17c059047534ae494691815 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:10:28 +0900 Subject: [PATCH 072/570] docs(changelog): record deterministic MCP tool catalog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4bd39ca..7b3b8edfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. +- Deterministic MCP 2026-07-28 stateless tool routing with bounded names, a single reviewed tool-to-action registry shared by routing and deterministic adapter discovery, and fail-closed policy binding that grants no ambient authority. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. From e241631b8198605f2a7996c295f3801416bf5df4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:31:47 +0900 Subject: [PATCH 073/570] test(mcp): require conservative tools list cache contract --- crates/originweave-core/tests/mcp_tools_list_cache.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 crates/originweave-core/tests/mcp_tools_list_cache.rs diff --git a/crates/originweave-core/tests/mcp_tools_list_cache.rs b/crates/originweave-core/tests/mcp_tools_list_cache.rs new file mode 100644 index 000000000..28d573379 --- /dev/null +++ b/crates/originweave-core/tests/mcp_tools_list_cache.rs @@ -0,0 +1,11 @@ +use originweave_core::mcp::{McpCacheScope, mcp_tools_list_page, supported_mcp_tools}; + +#[test] +fn mcp_tools_list_page_is_complete_private_and_immediately_stale() { + let page = mcp_tools_list_page(); + + assert_eq!(page.tools(), supported_mcp_tools()); + assert_eq!(page.ttl_ms(), 0); + assert_eq!(page.cache_scope(), McpCacheScope::Private); + assert_eq!(page.next_cursor(), None); +} From fb6ea61c0a615c1c5bc7f6fcc1f43109db24bd68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:33:46 +0900 Subject: [PATCH 074/570] feat(mcp): bind tools list to conservative cache hints --- crates/originweave-core/src/mcp.rs | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index c3ef61e15..6a07961d3 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -116,6 +116,76 @@ pub const fn supported_mcp_tools() -> &'static [McpToolCatalogEntry] { MCP_TOOL_CATALOG } +/// Cache-sharing scope for an MCP cacheable list result. +/// +/// OriginWeave currently exposes only the conservative private scope. A transport adapter must +/// serialize this as MCP's `"private"` cache scope and must not widen it without a separately +/// reviewed policy that proves the returned catalog is safe to share across callers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpCacheScope { + /// The result may be cached only for the current caller's private context. + Private, +} + +/// One typed MCP `tools/list` page derived from the reviewed tool catalog. +/// +/// This value is discovery metadata only. It does not grant any tool capability or action +/// authority. The initial contract is deliberately a single private page with zero freshness so +/// adapters cannot accidentally share or reuse discovery metadata beyond the current request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct McpToolsListPage { + tools: &'static [McpToolCatalogEntry], + ttl_ms: u64, + cache_scope: McpCacheScope, + next_cursor: Option<&'static str>, +} + +impl McpToolsListPage { + /// Return the deterministic reviewed tool entries in this page. + #[must_use] + pub const fn tools(&self) -> &'static [McpToolCatalogEntry] { + self.tools + } + + /// Return the MCP freshness lifetime in milliseconds. + /// + /// The current conservative contract is zero, so clients must treat the result as + /// immediately stale rather than reusing it for a later request. + #[must_use] + pub const fn ttl_ms(&self) -> u64 { + self.ttl_ms + } + + /// Return the MCP cache-sharing scope for this page. + #[must_use] + pub const fn cache_scope(&self) -> McpCacheScope { + self.cache_scope + } + + /// Return the opaque continuation cursor when another page exists. + /// + /// The current fixed catalog is emitted as one complete page, so this is always `None`. + #[must_use] + pub const fn next_cursor(&self) -> Option<&'static str> { + self.next_cursor + } +} + +/// Build the conservative typed MCP `tools/list` result for the reviewed catalog. +/// +/// This function does not perform transport serialization, authorization, or pagination. It +/// binds the catalog to explicit zero-TTL/private cache hints so adapters cannot invent a broader +/// cache policy independently from this reviewed boundary. +#[must_use] +pub const fn mcp_tools_list_page() -> McpToolsListPage { + McpToolsListPage { + tools: MCP_TOOL_CATALOG, + ttl_ms: 0, + cache_scope: McpCacheScope::Private, + next_cursor: None, + } +} + /// A deterministic failure while validating untrusted MCP routing metadata. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpToolBoundaryError { From 54fb93c1059ae623814c647a441ad14a4a8bcc51 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:37:17 +0900 Subject: [PATCH 075/570] docs(changelog): record MCP tools list cache contract --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b3b8edfb..2f3e240bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. - Deterministic MCP 2026-07-28 stateless tool routing with bounded names, a single reviewed tool-to-action registry shared by routing and deterministic adapter discovery, and fail-closed policy binding that grants no ambient authority. +- Conservative MCP 2026-07-28 `tools/list` discovery metadata derived from that reviewed catalog, with zero freshness, private cache scope, no continuation cursor, and no new action authority. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. @@ -53,7 +54,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Shortened, integer, hexadecimal, and legacy octal-looking IPv4 host spellings are rejected so the policy origin cannot diverge from Chromium host interpretation. - IPv4-mapped IPv6 is canonicalized before destination classification and pin comparison so mapped private or loopback addresses cannot bypass IPv4 policy. - The default destination policy permits only public addresses and denies unspecified, loopback, private, shared, link-local, metadata, documentation, benchmarking, multicast, broadcast, transition, and protocol-reserved destinations. -- Azure platform IP `168.63.129.16` and Amazon EKS Pod Identity endpoints `169.254.170.23` and `fd00:ec2::23` are classified as metadata or platform services before broader public, link-local, or unique-local rules. +- Azure platform IP `168.63.129.16` and Amazon EKS Pod Identity endpoints `169.254.170.23` and `fd00:ec2::23` are classified as metadata or platform services before broader address-range rules. - Resolver answers are rejected when empty or larger than 256 addresses, preventing an unbounded resolver response from entering policy state. - `localhost` may approve only loopback addresses, while literal IPv4 and IPv6 origins may approve only the exact canonical address encoded in the origin. - Resolver answers must remain a non-empty subset of the origin-bound approved address set; any newly introduced address fails closed as a possible DNS-rebinding event. From 7c8f69f31a0b60051b8c9dd443d5fb92568a0c1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:38:03 +0900 Subject: [PATCH 076/570] docs(doctoring): record MCP cache-hint boundary --- docs/doctoring/browser-agent-protocols.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 5173a32e6..1653510ed 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -1,6 +1,6 @@ # Browser and Agent Protocol Standards Evidence -- **Reviewed:** 2026-08-10 +- **Reviewed:** 2026-08-16 - **Purpose:** primary-source evidence for OriginWeave browser compatibility and adapter boundaries - **Canonical research index:** [`../doctoring.md`](../doctoring.md) @@ -38,6 +38,8 @@ Primary sources: Chrome for Developers, *WebMCP*; *WebMCP tool security*; *Agent The Model Context Protocol project released specification version `2026-07-28` on 28 July 2026. That release moved the protocol core toward stateless request/response operation and removed the earlier protocol-session assumptions described by previous releases. OriginWeave therefore keeps durable browser state in explicit OriginWeave application handles and exposes MCP only as a high-level adapter to the Rust runtime. MCP clients or servers do not connect models directly to Chromium/CDP authority. +The same release added explicit cache hints for cacheable result families, including `tools/list`: `ttlMs` expresses freshness lifetime and `cacheScope` expresses whether reuse is private or shareable. OriginWeave's first typed `tools/list` result therefore chooses the conservative boundary `ttlMs = 0` and private scope, derives the page directly from the reviewed tool catalog, and emits no continuation cursor for the current fixed single-page catalog. These metadata choices do not grant tool authority and do not claim JSON-RPC serialization, transport caching, OAuth, or a general pagination implementation. + Primary sources: Model Context Protocol, *2026-07-28 Specification* and the maintainers' official release announcement. ## Provenance standards @@ -51,8 +53,9 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re 3. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. 4. Keep WebMCP experimental/optional and propagate untrusted-content semantics. 5. Keep MCP browser state application-level rather than equating protocol transport/session metadata with browser authority. -6. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. -7. Treat WARC/PROV as provenance representations, not policy or truth escalation. +6. Bind MCP cacheable-list metadata to reviewed typed results; default to zero freshness and private scope unless a separate reviewed policy proves broader reuse safe. +7. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. +8. Treat WARC/PROV as provenance representations, not policy or truth escalation. ## References — APA 7th From fde152c1476b345c5825a5f43462fe4ae6ea66f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:03:09 +0900 Subject: [PATCH 077/570] test(mcp): require complete tools list result type --- crates/originweave-core/tests/mcp_tools_list_cache.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/mcp_tools_list_cache.rs b/crates/originweave-core/tests/mcp_tools_list_cache.rs index 28d573379..54c89e6b3 100644 --- a/crates/originweave-core/tests/mcp_tools_list_cache.rs +++ b/crates/originweave-core/tests/mcp_tools_list_cache.rs @@ -1,9 +1,12 @@ -use originweave_core::mcp::{McpCacheScope, mcp_tools_list_page, supported_mcp_tools}; +use originweave_core::mcp::{ + McpCacheScope, McpResultType, mcp_tools_list_page, supported_mcp_tools, +}; #[test] fn mcp_tools_list_page_is_complete_private_and_immediately_stale() { let page = mcp_tools_list_page(); + assert_eq!(page.result_type(), McpResultType::Complete); assert_eq!(page.tools(), supported_mcp_tools()); assert_eq!(page.ttl_ms(), 0); assert_eq!(page.cache_scope(), McpCacheScope::Private); From ea61611be3b075d3410e16889f9de15bf9628a7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:04:50 +0900 Subject: [PATCH 078/570] fix(mcp): bind mandatory tools list result type --- crates/originweave-core/src/mcp.rs | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index 6a07961d3..805fef3ee 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -116,6 +116,17 @@ pub const fn supported_mcp_tools() -> &'static [McpToolCatalogEntry] { MCP_TOOL_CATALOG } +/// Protocol disposition carried by a typed MCP result. +/// +/// OriginWeave currently constructs only terminal results at this boundary. A transport adapter +/// must serialize [`Self::Complete`] as MCP's `"complete"` result type and must not omit or +/// reinterpret the required protocol field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpResultType { + /// The request completed and this value contains the final result. + Complete, +} + /// Cache-sharing scope for an MCP cacheable list result. /// /// OriginWeave currently exposes only the conservative private scope. A transport adapter must @@ -130,10 +141,12 @@ pub enum McpCacheScope { /// One typed MCP `tools/list` page derived from the reviewed tool catalog. /// /// This value is discovery metadata only. It does not grant any tool capability or action -/// authority. The initial contract is deliberately a single private page with zero freshness so -/// adapters cannot accidentally share or reuse discovery metadata beyond the current request. +/// authority. The initial contract is deliberately one complete private page with zero freshness +/// so adapters cannot omit MCP's required result disposition or accidentally share or reuse +/// discovery metadata beyond the current request. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct McpToolsListPage { + result_type: McpResultType, tools: &'static [McpToolCatalogEntry], ttl_ms: u64, cache_scope: McpCacheScope, @@ -141,6 +154,12 @@ pub struct McpToolsListPage { } impl McpToolsListPage { + /// Return the mandatory MCP result disposition for this list page. + #[must_use] + pub const fn result_type(&self) -> McpResultType { + self.result_type + } + /// Return the deterministic reviewed tool entries in this page. #[must_use] pub const fn tools(&self) -> &'static [McpToolCatalogEntry] { @@ -174,11 +193,13 @@ impl McpToolsListPage { /// Build the conservative typed MCP `tools/list` result for the reviewed catalog. /// /// This function does not perform transport serialization, authorization, or pagination. It -/// binds the catalog to explicit zero-TTL/private cache hints so adapters cannot invent a broader -/// cache policy independently from this reviewed boundary. +/// binds the catalog to the mandatory complete result disposition plus explicit zero-TTL/private +/// cache hints so adapters cannot invent broader protocol or cache semantics independently from +/// this reviewed boundary. #[must_use] pub const fn mcp_tools_list_page() -> McpToolsListPage { McpToolsListPage { + result_type: McpResultType::Complete, tools: MCP_TOOL_CATALOG, ttl_ms: 0, cache_scope: McpCacheScope::Private, From cb14dc1265cc891e749752541e5071e6854012b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:07:55 +0900 Subject: [PATCH 079/570] docs(changelog): record complete MCP list result disposition --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3e240bf..3779c9168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. - Deterministic MCP 2026-07-28 stateless tool routing with bounded names, a single reviewed tool-to-action registry shared by routing and deterministic adapter discovery, and fail-closed policy binding that grants no ambient authority. -- Conservative MCP 2026-07-28 `tools/list` discovery metadata derived from that reviewed catalog, with zero freshness, private cache scope, no continuation cursor, and no new action authority. +- Conservative MCP 2026-07-28 `tools/list` discovery metadata derived from that reviewed catalog, with the mandatory `resultType = complete` disposition, zero freshness, private cache scope, no continuation cursor, and no new action authority. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. From 88e6464e7b720a0dcc730338a6259cdb70eb8af8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:08:26 +0900 Subject: [PATCH 080/570] docs(mcp): record mandatory complete result disposition --- docs/doctoring/browser-agent-protocols.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 1653510ed..4bb3a3dce 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -38,7 +38,7 @@ Primary sources: Chrome for Developers, *WebMCP*; *WebMCP tool security*; *Agent The Model Context Protocol project released specification version `2026-07-28` on 28 July 2026. That release moved the protocol core toward stateless request/response operation and removed the earlier protocol-session assumptions described by previous releases. OriginWeave therefore keeps durable browser state in explicit OriginWeave application handles and exposes MCP only as a high-level adapter to the Rust runtime. MCP clients or servers do not connect models directly to Chromium/CDP authority. -The same release added explicit cache hints for cacheable result families, including `tools/list`: `ttlMs` expresses freshness lifetime and `cacheScope` expresses whether reuse is private or shareable. OriginWeave's first typed `tools/list` result therefore chooses the conservative boundary `ttlMs = 0` and private scope, derives the page directly from the reviewed tool catalog, and emits no continuation cursor for the current fixed single-page catalog. These metadata choices do not grant tool authority and do not claim JSON-RPC serialization, transport caching, OAuth, or a general pagination implementation. +The same specification requires every Result to carry `resultType`, using `complete` for a terminal result, and adds explicit cache hints for cacheable result families including `tools/list`: `ttlMs` expresses freshness lifetime and `cacheScope` expresses whether reuse is private or shareable. OriginWeave's first typed `tools/list` result therefore binds `resultType = complete`, chooses the conservative boundary `ttlMs = 0` and private scope, derives the page directly from the reviewed tool catalog, and emits no continuation cursor for the current fixed single-page catalog. These metadata choices do not grant tool authority and do not claim JSON-RPC serialization, transport caching, OAuth, or a general pagination implementation. Primary sources: Model Context Protocol, *2026-07-28 Specification* and the maintainers' official release announcement. @@ -53,7 +53,7 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re 3. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. 4. Keep WebMCP experimental/optional and propagate untrusted-content semantics. 5. Keep MCP browser state application-level rather than equating protocol transport/session metadata with browser authority. -6. Bind MCP cacheable-list metadata to reviewed typed results; default to zero freshness and private scope unless a separate reviewed policy proves broader reuse safe. +6. Bind mandatory MCP result disposition and cacheable-list metadata to reviewed typed results; use a complete terminal result with zero freshness and private scope unless a separate reviewed policy proves broader semantics safe. 7. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. 8. Treat WARC/PROV as provenance representations, not policy or truth escalation. From afabb68c58b132f27f13ceb47bfbbd947f0fdedf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:45:36 +0000 Subject: [PATCH 081/570] feat(core): bind extension grants to canonical origin Keep an extension-to-Agent grant from surviving same-session navigation or a port change. RFC 6454 treats scheme, host, and port as the origin tuple, so evaluate_extension_access now requires the request origin to match the grant. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + crates/originweave-core/src/lib.rs | 17 +++++-- .../tests/extension_authority.rs | 51 +++++++++++++++++-- docs/TRD.md | 2 +- .../0013-manifest-v3-extension-authority.md | 2 +- docs/doctoring.md | 6 +++ .../extension-authority-security.md | 6 +++ 7 files changed, 77 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4bd39ca..3f1e88ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 88dd2e586..5f862b862 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -967,16 +967,18 @@ pub struct ExtensionAgentGrant { extension_id: ExtensionId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, + origin: Origin, capabilities: BTreeSet, } impl ExtensionAgentGrant { - /// Build an exact extension-to-Agent grant for one browser session and context. + /// Build an exact extension-to-Agent grant for one session, context, and origin. #[must_use] pub fn new( extension_id: ExtensionId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, + origin: Origin, capabilities: I, ) -> Self where @@ -986,6 +988,7 @@ impl ExtensionAgentGrant { extension_id, browser_session, browsing_context, + origin, capabilities: capabilities.into_iter().collect(), } } @@ -997,6 +1000,7 @@ pub struct ExtensionAccessRequest { extension_id: ExtensionId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, + origin: Origin, capability: ExtensionAgentCapability, } @@ -1007,12 +1011,14 @@ impl ExtensionAccessRequest { extension_id: ExtensionId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, + origin: Origin, capability: ExtensionAgentCapability, ) -> Self { Self { extension_id, browser_session, browsing_context, + origin, capability, } } @@ -1031,6 +1037,8 @@ pub enum ExtensionAccessDecision { DenyBrowserSessionMismatch, /// The request belongs to a different independently navigable browser context. DenyBrowsingContextMismatch, + /// The request belongs to a different canonical origin than the grant. + DenyOriginMismatch, /// The extension grant does not contain the requested OriginWeave capability. DenyCapabilityNotGranted, } @@ -1039,8 +1047,8 @@ pub enum ExtensionAccessDecision { /// /// A Chrome extension permission, installation state, or page capability is never /// consulted here. A future Chromium adapter must construct a host-originated -/// [`ExtensionAgentGrant`] explicitly and re-evaluate the exact session/context -/// request at the boundary where Agent authority would otherwise cross. +/// [`ExtensionAgentGrant`] explicitly and re-evaluate the exact session, context, +/// and canonical origin at the boundary where Agent authority would otherwise cross. #[must_use] pub fn evaluate_extension_access( request: &ExtensionAccessRequest, @@ -1058,6 +1066,9 @@ pub fn evaluate_extension_access( if request.browsing_context != grant.browsing_context { return ExtensionAccessDecision::DenyBrowsingContextMismatch; } + if request.origin != grant.origin { + return ExtensionAccessDecision::DenyOriginMismatch; + } if !grant.capabilities.contains(&request.capability) { return ExtensionAccessDecision::DenyCapabilityNotGranted; } diff --git a/crates/originweave-core/tests/extension_authority.rs b/crates/originweave-core/tests/extension_authority.rs index 82507a244..7c12ac0d9 100644 --- a/crates/originweave-core/tests/extension_authority.rs +++ b/crates/originweave-core/tests/extension_authority.rs @@ -2,7 +2,7 @@ use originweave_core::{ BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, ExtensionAccessRequest, - ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, evaluate_extension_access, + ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, evaluate_extension_access, }; fn extension_id(value: &str) -> ExtensionId { @@ -17,6 +17,10 @@ fn context(value: u64) -> BrowsingContextId { BrowsingContextId::new(value).expect("nonzero browsing context") } +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("canonical origin") +} + #[test] fn extension_id_accepts_only_canonical_chromium_extension_ids() { let canonical = "abcdefghijklmnopabcdefghijklmnop"; @@ -43,10 +47,12 @@ fn extension_id_accepts_only_canonical_chromium_extension_ids() { fn extension_agent_access_requires_an_explicit_exact_grant() { let allowed_extension = extension_id("abcdefghijklmnopabcdefghijklmnop"); let other_extension = extension_id("bcdefghijklmnopabcdefghijklmnopa"); + let granted_origin = origin("https://app.example"); let grant = ExtensionAgentGrant::new( allowed_extension.clone(), session(7), context(11), + granted_origin.clone(), [ExtensionAgentCapability::ObserveCurrentContext], ); @@ -54,6 +60,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { allowed_extension.clone(), session(7), context(11), + granted_origin.clone(), ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -68,6 +75,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { other_extension, session(7), context(11), + granted_origin.clone(), ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -79,6 +87,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { allowed_extension.clone(), session(8), context(11), + granted_origin.clone(), ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -87,24 +96,51 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { ); let wrong_context = ExtensionAccessRequest::new( - allowed_extension, + allowed_extension.clone(), session(7), context(12), + granted_origin.clone(), ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( evaluate_extension_access(&wrong_context, Some(&grant)), ExtensionAccessDecision::DenyBrowsingContextMismatch ); + + let wrong_origin = ExtensionAccessRequest::new( + allowed_extension.clone(), + session(7), + context(11), + origin("https://other.example"), + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&wrong_origin, Some(&grant)), + ExtensionAccessDecision::DenyOriginMismatch + ); + + let wrong_port = ExtensionAccessRequest::new( + allowed_extension, + session(7), + context(11), + origin("https://app.example:8443"), + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&wrong_port, Some(&grant)), + ExtensionAccessDecision::DenyOriginMismatch + ); } #[test] fn chrome_permissions_never_imply_originweave_agent_capabilities() { let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("https://mail.example"); let grant = ExtensionAgentGrant::new( id.clone(), session(3), context(5), + granted_origin.clone(), [ExtensionAgentCapability::ObserveCurrentContext], ); @@ -112,6 +148,7 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { id, session(3), context(5), + granted_origin, ExtensionAgentCapability::ProposeTypedAction, ); assert_eq!( @@ -123,10 +160,12 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { #[test] fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("http://127.0.0.1:8080"); let grant = ExtensionAgentGrant::new( id.clone(), session(13), context(17), + granted_origin.clone(), [ ExtensionAgentCapability::ObserveCurrentContext, ExtensionAgentCapability::ProposeTypedAction, @@ -137,7 +176,13 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { ExtensionAgentCapability::ObserveCurrentContext, ExtensionAgentCapability::ProposeTypedAction, ] { - let request = ExtensionAccessRequest::new(id.clone(), session(13), context(17), capability); + let request = ExtensionAccessRequest::new( + id.clone(), + session(13), + context(17), + granted_origin.clone(), + capability, + ); assert_eq!( evaluate_extension_access(&request, Some(&grant)), ExtensionAccessDecision::Allow diff --git a/docs/TRD.md b/docs/TRD.md index 3e8030012..0caf7feb4 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -25,7 +25,7 @@ The current reusable Rust control plane is intentionally smaller than the final | Module / boundary | Current responsibility | Protected-main status | Active/non-shipped evidence | |---|---|---|---| -| `originweave-core` | Canonical origin, typed actions, purpose/mode, capabilities, risk, secret-delivery, approval, session/context/document/node authority values. | **Implemented** | PR #40 builds a protocol-ID registry on top of these values; it is not protected-main truth | +| `originweave-core` | Canonical origin, typed actions, purpose/mode, capabilities, risk, secret-delivery, approval, session/context/document/node authority values. | **Implemented** | Active origin-bound `ExtensionAgentGrant` evaluation adds canonical-origin matching to the existing extension/session/context grant; it is not protected-main truth until merge | | `originweave-policy` | Pure fail-closed action policy including purpose-bound sensitive-data authority. | **Implemented** | Trusted broker/runtime lifecycle remains separate planned work under issue #10 | | `originweave-destination` | Resolved-address classification, origin-bound snapshots, route authority, connection pinning, rebinding and redirect authority. | **Implemented** | PAC evaluation/proxy transport/CONNECT are still Planned | | `originweave-network` | Direct single-address TCP connection plan and exact operating-system peer verification. | **Implemented** | — | diff --git a/docs/adr/0013-manifest-v3-extension-authority.md b/docs/adr/0013-manifest-v3-extension-authority.md index e620edf9d..064f87f55 100644 --- a/docs/adr/0013-manifest-v3-extension-authority.md +++ b/docs/adr/0013-manifest-v3-extension-authority.md @@ -92,7 +92,7 @@ No persistent database migration is introduced. A release can roll back the Chro ## Open follow-ups -- Complete issue #27's compatibility matrix and production isolation acceptance. +- Complete issue #27's compatibility matrix and production isolation acceptance. Origin-bound `ExtensionAgentGrant` evaluation is the next protected-main candidate for the origin/resource-scope rule; expiry and task binding remain open. - Define managed-extension identity/update semantics. - Implement the native-messaging allow-list/process boundary before claiming support. - Integrate the complete Agent Task browser vertical slice under issue #28. diff --git a/docs/doctoring.md b/docs/doctoring.md index 75c107ef0..1d23fc4b6 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -14,6 +14,10 @@ The WHATWG URL host parser and Chromium canonicalizer classify shortened decimal The exact Chromium regression evidence is pinned to revision `446d05d21720f0b3505ec21057b3e9f909784262`. A mutable `HEAD` reference is not sufficient for a reproducible security contract. +### Extension-to-Agent grant origin binding + +RFC 6454 defines a web origin as the scheme, host, and port tuple that browsers use to isolate authority. An OriginWeave `extension_grant` that is bound only to extension identity, session, and browsing context would remain valid after the same context navigates to another origin. OriginWeave therefore requires the grant and the request to carry the same canonical origin. A host change or a non-default port change is a different origin and cannot reuse the grant. This is grant-scope isolation only; it does not install an extension, parse Chrome messages, or mint Agent capabilities from Manifest V3 permissions. + ### Resolved destination and redirect safety Canonical origin identity is not a network-destination authorization. The IANA IPv4 and IPv6 Special-Purpose Address Space registries enumerate blocks whose source, destination, forwardability, globally reachable, and protocol-reserved properties differ. Both registries were last updated on 9 October 2025 and explicitly warn that registry presence does not guarantee routability in a particular local or global context. RFC 6890 established the common special-purpose registry fields, and RFC 8190 replaced the ambiguous `global` field with `globally reachable`. @@ -96,6 +100,8 @@ Amazon Web Services. (n.d.). *Set up the Amazon EKS Pod Identity Agent*. Retriev Autio, C., Schwartz, R., Dunietz, J., Jain, S., Stanley, M., Tabassi, E., Hall, P., & Roberts, K. (2024). *Artificial intelligence risk management framework: Generative artificial intelligence profile* (NIST AI 600-1). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.AI.600-1 +Barth, A. (2011). *The web origin concept* (RFC 6454). Internet Engineering Task Force. https://doi.org/10.17487/RFC6454 + Bonica, R., Cotton, M., Haberman, B., & Vegoda, L. (2017). *Updates to the special-purpose IP address registries* (RFC 8190). Internet Engineering Task Force. https://doi.org/10.17487/RFC8190 Chromium Authors. (n.d.). *Proxy support in Chrome* [Source documentation]. Chromium. https://chromium.googlesource.com/chromium/src/+/a3e71ebfa307d8760eb68b777e2998a869940092/net/docs/proxy.md diff --git a/docs/traceability/extension-authority-security.md b/docs/traceability/extension-authority-security.md index a36380a31..62143e601 100644 --- a/docs/traceability/extension-authority-security.md +++ b/docs/traceability/extension-authority-security.md @@ -50,6 +50,12 @@ Exact head `e83749acd1cf5a0b778ba38eb9d6ed5a9bd1e68f` deliberately keeps only th The exact head has successful CI, exact owned production coverage, Security Scan, SAST and CodeRabbit status and is Ready for review. It has no raw secret bytes and does not create approval evidence, a broker, browser-fill adapter, protected-value store, KMS path, authenticated workload identity, persistence owner, or release claim. +### Origin-bound extension grant evaluation + +**Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` + +The current origin-binding slice requires `ExtensionAgentGrant` and `ExtensionAccessRequest` to carry the same canonical origin. A same-session, same-context request for `https://other.example` or `https://app.example:8443` against a grant for `https://app.example` is `DenyOriginMismatch`. This does not install an extension, parse Chrome messages, bind expiry or task identity, or mint Agent capabilities from Manifest V3 permissions. + ## 4. Security interpretation The executable authority chain is intentionally non-transitive: From 16a872cddd0e336adfe0a686a75d057c05d8c74a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:51:21 +0000 Subject: [PATCH 082/570] feat(core): expire extension grants at exclusive trusted time Bind ExtensionAgentGrant to an exclusive expiry and require trusted evaluation time on ExtensionAccessRequest so a same-origin grant cannot be reused at or after the Agent Task deadline. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + crates/originweave-core/src/lib.rs | 21 +++++- .../tests/extension_authority.rs | 68 +++++++++++++++++++ docs/TRD.md | 2 +- .../0013-manifest-v3-extension-authority.md | 2 +- docs/doctoring.md | 6 ++ .../extension-authority-security.md | 2 +- 7 files changed, 96 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f1e88ef8..d17419927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 5f862b862..b6ed55ff2 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -968,17 +968,19 @@ pub struct ExtensionAgentGrant { browser_session: BrowserSessionId, browsing_context: BrowsingContextId, origin: Origin, + expires_at_epoch_seconds: u64, capabilities: BTreeSet, } impl ExtensionAgentGrant { - /// Build an exact extension-to-Agent grant for one session, context, and origin. + /// Build an exact extension-to-Agent grant for one session, context, origin, and exclusive expiry. #[must_use] pub fn new( extension_id: ExtensionId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, origin: Origin, + expires_at_epoch_seconds: u64, capabilities: I, ) -> Self where @@ -989,6 +991,7 @@ impl ExtensionAgentGrant { browser_session, browsing_context, origin, + expires_at_epoch_seconds, capabilities: capabilities.into_iter().collect(), } } @@ -1001,17 +1004,22 @@ pub struct ExtensionAccessRequest { browser_session: BrowserSessionId, browsing_context: BrowsingContextId, origin: Origin, + now_epoch_seconds: u64, capability: ExtensionAgentCapability, } impl ExtensionAccessRequest { /// Build one exact extension capability request without granting authority. + /// + /// `now_epoch_seconds` must be trusted evaluation time supplied by the host, + /// not a page, extension, or model clock. #[must_use] pub const fn new( extension_id: ExtensionId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, origin: Origin, + now_epoch_seconds: u64, capability: ExtensionAgentCapability, ) -> Self { Self { @@ -1019,6 +1027,7 @@ impl ExtensionAccessRequest { browser_session, browsing_context, origin, + now_epoch_seconds, capability, } } @@ -1027,7 +1036,7 @@ impl ExtensionAccessRequest { /// Result of evaluating an extension request against one explicit Agent grant. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExtensionAccessDecision { - /// The exact extension, session, context, and capability are explicitly granted. + /// The exact extension, session, context, origin, unexpired grant, and capability are explicitly granted. Allow, /// No explicit extension-to-Agent grant was supplied. DenyMissingGrant, @@ -1039,6 +1048,8 @@ pub enum ExtensionAccessDecision { DenyBrowsingContextMismatch, /// The request belongs to a different canonical origin than the grant. DenyOriginMismatch, + /// Trusted evaluation time is at or after the grant's exclusive expiry. + DenyExpired, /// The extension grant does not contain the requested OriginWeave capability. DenyCapabilityNotGranted, } @@ -1048,7 +1059,8 @@ pub enum ExtensionAccessDecision { /// A Chrome extension permission, installation state, or page capability is never /// consulted here. A future Chromium adapter must construct a host-originated /// [`ExtensionAgentGrant`] explicitly and re-evaluate the exact session, context, -/// and canonical origin at the boundary where Agent authority would otherwise cross. +/// canonical origin, and exclusive expiry at the boundary where Agent authority +/// would otherwise cross. #[must_use] pub fn evaluate_extension_access( request: &ExtensionAccessRequest, @@ -1069,6 +1081,9 @@ pub fn evaluate_extension_access( if request.origin != grant.origin { return ExtensionAccessDecision::DenyOriginMismatch; } + if request.now_epoch_seconds >= grant.expires_at_epoch_seconds { + return ExtensionAccessDecision::DenyExpired; + } if !grant.capabilities.contains(&request.capability) { return ExtensionAccessDecision::DenyCapabilityNotGranted; } diff --git a/crates/originweave-core/tests/extension_authority.rs b/crates/originweave-core/tests/extension_authority.rs index 7c12ac0d9..f34c30e9b 100644 --- a/crates/originweave-core/tests/extension_authority.rs +++ b/crates/originweave-core/tests/extension_authority.rs @@ -21,6 +21,9 @@ fn origin(value: &str) -> Origin { Origin::parse(value).expect("canonical origin") } +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + #[test] fn extension_id_accepts_only_canonical_chromium_extension_ids() { let canonical = "abcdefghijklmnopabcdefghijklmnop"; @@ -53,6 +56,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { session(7), context(11), granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ObserveCurrentContext], ); @@ -61,6 +65,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { session(7), context(11), granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -76,6 +81,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { session(7), context(11), granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -88,6 +94,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { session(8), context(11), granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -100,6 +107,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { session(7), context(12), granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -112,6 +120,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { session(7), context(11), origin("https://other.example"), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -124,6 +133,7 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { session(7), context(11), origin("https://app.example:8443"), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -141,6 +151,7 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { session(3), context(5), granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ObserveCurrentContext], ); @@ -149,6 +160,7 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { session(3), context(5), granted_origin, + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ProposeTypedAction, ); assert_eq!( @@ -166,6 +178,7 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { session(13), context(17), granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ ExtensionAgentCapability::ObserveCurrentContext, ExtensionAgentCapability::ProposeTypedAction, @@ -181,6 +194,7 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { session(13), context(17), granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, capability, ); assert_eq!( @@ -189,3 +203,57 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { ); } } + +#[test] +fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { + let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("https://billing.example"); + let expires_at_epoch_seconds = 1_700_000_100; + let grant = ExtensionAgentGrant::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds, + [ExtensionAgentCapability::ObserveCurrentContext], + ); + + let before_deadline = ExtensionAccessRequest::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds - 1, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&before_deadline, Some(&grant)), + ExtensionAccessDecision::Allow + ); + + let at_deadline = ExtensionAccessRequest::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&at_deadline, Some(&grant)), + ExtensionAccessDecision::DenyExpired + ); + + let after_deadline = ExtensionAccessRequest::new( + id, + session(19), + context(23), + granted_origin, + expires_at_epoch_seconds + 1, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&after_deadline, Some(&grant)), + ExtensionAccessDecision::DenyExpired + ); +} diff --git a/docs/TRD.md b/docs/TRD.md index 0caf7feb4..0e60e5ca5 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -25,7 +25,7 @@ The current reusable Rust control plane is intentionally smaller than the final | Module / boundary | Current responsibility | Protected-main status | Active/non-shipped evidence | |---|---|---|---| -| `originweave-core` | Canonical origin, typed actions, purpose/mode, capabilities, risk, secret-delivery, approval, session/context/document/node authority values. | **Implemented** | Active origin-bound `ExtensionAgentGrant` evaluation adds canonical-origin matching to the existing extension/session/context grant; it is not protected-main truth until merge | +| `originweave-core` | Canonical origin, typed actions, purpose/mode, capabilities, risk, secret-delivery, approval, session/context/document/node authority values. | **Implemented** | Active origin-bound `ExtensionAgentGrant` evaluation adds canonical-origin matching and exclusive trusted-time expiry; it is not protected-main truth until merge | | `originweave-policy` | Pure fail-closed action policy including purpose-bound sensitive-data authority. | **Implemented** | Trusted broker/runtime lifecycle remains separate planned work under issue #10 | | `originweave-destination` | Resolved-address classification, origin-bound snapshots, route authority, connection pinning, rebinding and redirect authority. | **Implemented** | PAC evaluation/proxy transport/CONNECT are still Planned | | `originweave-network` | Direct single-address TCP connection plan and exact operating-system peer verification. | **Implemented** | — | diff --git a/docs/adr/0013-manifest-v3-extension-authority.md b/docs/adr/0013-manifest-v3-extension-authority.md index 064f87f55..8feacbf27 100644 --- a/docs/adr/0013-manifest-v3-extension-authority.md +++ b/docs/adr/0013-manifest-v3-extension-authority.md @@ -92,7 +92,7 @@ No persistent database migration is introduced. A release can roll back the Chro ## Open follow-ups -- Complete issue #27's compatibility matrix and production isolation acceptance. Origin-bound `ExtensionAgentGrant` evaluation is the next protected-main candidate for the origin/resource-scope rule; expiry and task binding remain open. +- Complete issue #27's compatibility matrix and production isolation acceptance. Exclusive trusted-time expiry on origin-bound `ExtensionAgentGrant` evaluation is the next protected-main candidate; task identity binding remains open. - Define managed-extension identity/update semantics. - Implement the native-messaging allow-list/process boundary before claiming support. - Integrate the complete Agent Task browser vertical slice under issue #28. diff --git a/docs/doctoring.md b/docs/doctoring.md index 1d23fc4b6..f0133bb5d 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -18,6 +18,10 @@ The exact Chromium regression evidence is pinned to revision `446d05d21720f0b350 RFC 6454 defines a web origin as the scheme, host, and port tuple that browsers use to isolate authority. An OriginWeave `extension_grant` that is bound only to extension identity, session, and browsing context would remain valid after the same context navigates to another origin. OriginWeave therefore requires the grant and the request to carry the same canonical origin. A host change or a non-default port change is a different origin and cannot reuse the grant. This is grant-scope isolation only; it does not install an extension, parse Chrome messages, or mint Agent capabilities from Manifest V3 permissions. +### Extension-to-Agent grant exclusive expiry + +RFC 9700 is the current Best Current Practice for OAuth 2.0 security. It requires access tokens to be restricted in lifetime and treats long-lived bearer credentials as a standing authorization risk. An OriginWeave `extension_grant` that matches extension identity, session, browsing context, and canonical origin but has no exclusive expiry remains usable after the Agent Task window ends. OriginWeave therefore requires the grant to carry an exclusive `expires_at_epoch_seconds` deadline and the request to carry trusted `now_epoch_seconds`. Evaluation fails closed when `now >= expires_at`, matching the existing sensitive-handle exclusive-expiry rule. Page, extension, and model clocks are not trusted time. This slice does not bind task identity, install an extension, or mint Agent capabilities from Manifest V3 permissions. + ### Resolved destination and redirect safety Canonical origin identity is not a network-destination authorization. The IANA IPv4 and IPv6 Special-Purpose Address Space registries enumerate blocks whose source, destination, forwardability, globally reachable, and protocol-reserved properties differ. Both registries were last updated on 9 October 2025 and explicitly warn that registry presence does not guarantee routability in a particular local or global context. RFC 6890 established the common special-purpose registry fields, and RFC 8190 replaced the ambiguous `global` field with `globally reachable`. @@ -134,6 +138,8 @@ International Organization for Standardization. (2017). *Information and documen Koster, M., Illyes, G., Zeller, H., & Sassman, L. (2022). *Robots Exclusion Protocol* (RFC 9309). Internet Engineering Task Force. https://doi.org/10.17487/RFC9309 +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *OAuth 2.0 security best current practice* (RFC 9700). Internet Engineering Task Force. https://doi.org/10.17487/RFC9700 + Microsoft. (2025, July 25). *Azure IP address 168.63.129.16 overview*. Microsoft Learn. https://learn.microsoft.com/azure/virtual-network/what-is-ip-address-168-63-129-16 Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). *Learning to orchestrate agents in natural language with the Conductor* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04388 diff --git a/docs/traceability/extension-authority-security.md b/docs/traceability/extension-authority-security.md index 62143e601..1c211f83d 100644 --- a/docs/traceability/extension-authority-security.md +++ b/docs/traceability/extension-authority-security.md @@ -54,7 +54,7 @@ The exact head has successful CI, exact owned production coverage, Security Scan **Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` -The current origin-binding slice requires `ExtensionAgentGrant` and `ExtensionAccessRequest` to carry the same canonical origin. A same-session, same-context request for `https://other.example` or `https://app.example:8443` against a grant for `https://app.example` is `DenyOriginMismatch`. This does not install an extension, parse Chrome messages, bind expiry or task identity, or mint Agent capabilities from Manifest V3 permissions. +The current origin-binding slice requires `ExtensionAgentGrant` and `ExtensionAccessRequest` to carry the same canonical origin. A same-session, same-context request for `https://other.example` or `https://app.example:8443` against a grant for `https://app.example` is `DenyOriginMismatch`. Exclusive trusted-time expiry is evaluated after that origin match: `now >= expires_at` is `DenyExpired`. This does not install an extension, parse Chrome messages, bind task identity, or mint Agent capabilities from Manifest V3 permissions. ## 4. Security interpretation From 9c7981d10acf482e955eb739224f64ae98d0068b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:28:46 +0900 Subject: [PATCH 083/570] test(mcp): validate tool shape before route correlation --- .../tests/mcp_authority_route.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/originweave-core/tests/mcp_authority_route.rs b/crates/originweave-core/tests/mcp_authority_route.rs index df359ccf0..a1b1b6fc3 100644 --- a/crates/originweave-core/tests/mcp_authority_route.rs +++ b/crates/originweave-core/tests/mcp_authority_route.rs @@ -221,6 +221,43 @@ fn mcp_route_rejects_unbounded_malformed_and_unmapped_tool_names() { ); } +#[test] +fn mcp_route_validates_each_untrusted_tool_name_before_cross_field_comparison() { + let oversized_routing = "r".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); + let oversized_body = "b".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); + + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + &oversized_routing, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidToolName) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + &oversized_body, + ), + Err(McpToolBoundaryError::InvalidToolName) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave/observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidToolName) + ); +} + #[test] fn mcp_boundary_errors_are_deterministic_and_do_not_echo_untrusted_values() { let cases = [ From 1ff5d5f3acc3ed81edf17d3e4733adfb216cd7c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 04:30:12 +0900 Subject: [PATCH 084/570] fix(mcp): validate tool names before route correlation --- crates/originweave-core/src/mcp.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index 805fef3ee..72b222ff7 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -258,7 +258,9 @@ impl ValidatedMcpToolCall { /// Routing integrity is intentionally narrower than authorization. A /// successful value proves only that the untrusted protocol version, /// routing metadata, body method, and body tool name agree with one - /// explicitly supported mapping. + /// explicitly supported mapping. Each untrusted tool name is shape-validated + /// before cross-field comparison so malformed or oversized names cannot + /// bypass the bounded routing syntax through mismatch handling. pub fn new( protocol_version: &str, routing_method: &str, @@ -269,15 +271,15 @@ impl ValidatedMcpToolCall { if protocol_version != MCP_PROTOCOL_VERSION { return Err(McpToolBoundaryError::UnsupportedProtocolVersion); } + if !valid_tool_name(routing_tool_name) || !valid_tool_name(body_tool_name) { + return Err(McpToolBoundaryError::InvalidToolName); + } if routing_method != body_method || routing_tool_name != body_tool_name { return Err(McpToolBoundaryError::HeaderBodyMismatch); } if routing_method != MCP_TOOLS_CALL_METHOD { return Err(McpToolBoundaryError::UnsupportedMethod); } - if !valid_tool_name(routing_tool_name) { - return Err(McpToolBoundaryError::InvalidToolName); - } let (tool_name, action_kind) = map_tool(routing_tool_name)?; Ok(Self { From 3690bf0a351b77957071f5399e9a31cec5f39e0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:28:31 +0900 Subject: [PATCH 085/570] test(policy): align extension isolation grant scope --- .../originweave-policy/tests/extension_policy_isolation.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/originweave-policy/tests/extension_policy_isolation.rs b/crates/originweave-policy/tests/extension_policy_isolation.rs index 79557e49b..f32d8733c 100644 --- a/crates/originweave-policy/tests/extension_policy_isolation.rs +++ b/crates/originweave-policy/tests/extension_policy_isolation.rs @@ -13,6 +13,9 @@ use originweave_policy::{Decision, DenialReason, evaluate}; const VALID_INTENT: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EXTENSION_ORIGIN: &str = "https://extension.example"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; fn extension_id() -> ExtensionId { ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") @@ -39,6 +42,8 @@ fn action_proposal_grant() -> ExtensionAgentGrant { extension_id(), browser_session(), browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ProposeTypedAction], ) } @@ -48,6 +53,8 @@ fn assert_extension_can_only_propose(grant: &ExtensionAgentGrant) { extension_id(), browser_session(), browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ProposeTypedAction, ); assert_eq!( From b80963fb81bed1ac4a01c1118f498af9765e2b79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:29:26 +0900 Subject: [PATCH 086/570] test(policy): align secret isolation grant scope --- .../originweave-policy/tests/extension_secret_isolation.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/originweave-policy/tests/extension_secret_isolation.rs b/crates/originweave-policy/tests/extension_secret_isolation.rs index ad4293d0a..f808bec04 100644 --- a/crates/originweave-policy/tests/extension_secret_isolation.rs +++ b/crates/originweave-policy/tests/extension_secret_isolation.rs @@ -13,6 +13,8 @@ use originweave_policy::{Decision, evaluate}; const VALID_INTENT: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; fn extension_id() -> ExtensionId { ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") @@ -39,6 +41,8 @@ fn action_proposal_grant() -> ExtensionAgentGrant { extension_id(), browser_session(), browsing_context(), + origin(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ProposeTypedAction], ) } @@ -48,6 +52,8 @@ fn assert_extension_can_propose(grant: &ExtensionAgentGrant) { extension_id(), browser_session(), browsing_context(), + origin(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ProposeTypedAction, ); assert_eq!( From 9ce259834095cef91052db1e826f283689fb688f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:34:01 +0900 Subject: [PATCH 087/570] test(policy): align extension mutation grant scope --- .../tests/extension_mutation_isolation.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/originweave-policy/tests/extension_mutation_isolation.rs b/crates/originweave-policy/tests/extension_mutation_isolation.rs index 97d9b184b..48d7936e1 100644 --- a/crates/originweave-policy/tests/extension_mutation_isolation.rs +++ b/crates/originweave-policy/tests/extension_mutation_isolation.rs @@ -4,8 +4,9 @@ //! //! OriginWeave does not yet implement an adapter that converts an extension proposal into an //! [`ActionRequest`]. These regressions therefore prove two independent fail-closed boundaries: -//! the exact extension/session/context grant permits only `ProposeTypedAction`, while an ordinary -//! user-sourced action request remains subject to the core policy decision shown in each test. +//! the exact extension/session/context/origin/unexpired grant permits only `ProposeTypedAction`, +//! while an ordinary user-sourced action request remains subject to the core policy decision +//! shown in each test. use std::collections::BTreeSet; @@ -20,6 +21,9 @@ use originweave_policy::{Decision, DenialReason, evaluate}; const VALID_INTENT: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EXTENSION_ORIGIN: &str = "https://extension.example"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; fn extension_id() -> ExtensionId { ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") @@ -46,6 +50,8 @@ fn action_proposal_grant() -> ExtensionAgentGrant { extension_id(), browser_session(), browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ProposeTypedAction], ) } @@ -55,6 +61,8 @@ fn assert_proposal_grant_is_independently_allowed(grant: &ExtensionAgentGrant) { extension_id(), browser_session(), browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ProposeTypedAction, ); assert_eq!( From 6d56cd0b06d078b9dffefa803ddf56afe15ec977 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 17:10:47 +0900 Subject: [PATCH 088/570] docs: restore MCP tools/list changelog after stack sync --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91cb1666c..8fb31112f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. - Deterministic MCP 2026-07-28 stateless tool routing with bounded names, a single reviewed tool-to-action registry shared by routing and deterministic adapter discovery, and fail-closed policy binding that grants no ambient authority. +- Conservative MCP 2026-07-28 `tools/list` discovery metadata derived from that reviewed catalog, with the mandatory `resultType = complete` disposition, zero freshness, private cache scope, no continuation cursor, and no new action authority. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. From 1d86d28c5e44fac228807446ee6046d9e5982a42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 17:12:25 +0900 Subject: [PATCH 089/570] test(mcp): reject malformed cross-field tool names before mismatch --- .../tests/mcp_authority_route.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/originweave-core/tests/mcp_authority_route.rs b/crates/originweave-core/tests/mcp_authority_route.rs index df359ccf0..a1b1b6fc3 100644 --- a/crates/originweave-core/tests/mcp_authority_route.rs +++ b/crates/originweave-core/tests/mcp_authority_route.rs @@ -221,6 +221,43 @@ fn mcp_route_rejects_unbounded_malformed_and_unmapped_tool_names() { ); } +#[test] +fn mcp_route_validates_each_untrusted_tool_name_before_cross_field_comparison() { + let oversized_routing = "r".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); + let oversized_body = "b".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); + + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + &oversized_routing, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidToolName) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + &oversized_body, + ), + Err(McpToolBoundaryError::InvalidToolName) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave/observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidToolName) + ); +} + #[test] fn mcp_boundary_errors_are_deterministic_and_do_not_echo_untrusted_values() { let cases = [ From 9d0f524e47b37dd92971c094893a68b9f7307eff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 17:14:14 +0900 Subject: [PATCH 090/570] fix(mcp): validate each untrusted tool name before comparison --- crates/originweave-core/src/mcp.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index c3ef61e15..dd8d60258 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -167,7 +167,9 @@ impl ValidatedMcpToolCall { /// Routing integrity is intentionally narrower than authorization. A /// successful value proves only that the untrusted protocol version, /// routing metadata, body method, and body tool name agree with one - /// explicitly supported mapping. + /// explicitly supported mapping. Each untrusted tool name is shape-validated + /// before cross-field comparison so malformed or oversized names cannot + /// bypass the bounded routing syntax through mismatch handling. pub fn new( protocol_version: &str, routing_method: &str, @@ -178,15 +180,15 @@ impl ValidatedMcpToolCall { if protocol_version != MCP_PROTOCOL_VERSION { return Err(McpToolBoundaryError::UnsupportedProtocolVersion); } + if !valid_tool_name(routing_tool_name) || !valid_tool_name(body_tool_name) { + return Err(McpToolBoundaryError::InvalidToolName); + } if routing_method != body_method || routing_tool_name != body_tool_name { return Err(McpToolBoundaryError::HeaderBodyMismatch); } if routing_method != MCP_TOOLS_CALL_METHOD { return Err(McpToolBoundaryError::UnsupportedMethod); } - if !valid_tool_name(routing_tool_name) { - return Err(McpToolBoundaryError::InvalidToolName); - } let (tool_name, action_kind) = map_tool(routing_tool_name)?; Ok(Self { From 2fb12da27991e01764fb95df5a207f86927923e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 18:38:01 +0900 Subject: [PATCH 091/570] docs(mcp): distinguish active routing from shipped adapter --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91cb1666c..965d4a2d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,13 @@ All notable changes to OriginWeave are documented in this file. The format follo - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. -- Deterministic MCP 2026-07-28 stateless tool routing with bounded names, a single reviewed tool-to-action registry shared by routing and deterministic adapter discovery, and fail-closed policy binding that grants no ambient authority. +- Active PR #168 adds deterministic MCP `2026-07-28` stateless tool-routing foundations with bounded names, a single reviewed tool-to-action registry shared by routing and discovery metadata, and fail-closed policy binding that grants no ambient authority. This is active-PR evidence only; the complete MCP adapter, transport serialization, discovery response handling, OAuth, browser I/O, and persistence remain planned until separately integrated on protected `main`. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. - Authenticated `originweave-tls` service-identity boundary that consumes an existing verified TCP stream, requires exact TLS-origin and transport-origin equality, derives RFC 9525 DNS or literal-IP reference identity only from the canonical HTTPS origin, validates WebPKI with explicit roots and fixed time, permits only TLS 1.2 and TLS 1.3, and never reconnects or resolves. - Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior and `NotConfigured` revocation evidence. -- Credential-free TLS evidence containing canonical origin, requested and observed peer, DNS/IP reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration. +- Credential-free TLS evidence containing canonical origin, TCP peers, reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration. - Credential-free connection and redirect evidence containing canonical addresses, destination classes, target digests, hop numbers, and approved-address counts. - Credential-free verified TCP evidence containing the logical origin, requested socket, observed peer, destination class, successful attempt number, and per-attempt timeout. - Standard `Display` and `std::error::Error` contracts for destination, redirect, digest, direct-network, and TLS failures, including preserved destination-policy, rustls, and operating-system sources where applicable. From 9bc35d1314bbfbcf98368de1a08ba8f55668a212 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 18:38:29 +0900 Subject: [PATCH 092/570] docs(mcp): record active routing authority boundary --- .../0107-browser-protocol-adapter-strategy.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/adr/0107-browser-protocol-adapter-strategy.md b/docs/adr/0107-browser-protocol-adapter-strategy.md index 8923616be..1c71105d2 100644 --- a/docs/adr/0107-browser-protocol-adapter-strategy.md +++ b/docs/adr/0107-browser-protocol-adapter-strategy.md @@ -34,6 +34,14 @@ OriginWeave exposes its own versioned protocol for session, observation, query, MCP version negotiation is independent of the OriginWeave Protocol version. As of this review, MCP `2026-07-28` is the current released protocol generation; a future MCP change does not silently alter OriginWeave task, approval, secret, tenant, or browser semantics. MCP tool/resource content remains untrusted input and any server-to-client/user interaction capability is mediated by the same OriginWeave policy/approval boundaries as other adapter traffic. +### Current implementation boundary + +The complete MCP adapter remains **Planned**. Active PR #168 is narrower **IMPLEMENTED_ON_ACTIVE_PR** evidence inside the Rust control plane: it validates the `2026-07-28` stateless `tools/call` routing envelope presented to this boundary, bounds and syntax-checks both untrusted tool-name fields, derives one of the existing typed `ActionKind` values from a deterministic reviewed registry, exposes discovery metadata from that same registry, and requires the resulting action to pass the ordinary OriginWeave policy evaluator. The catalog and validated route grant no capability, approval, origin, secret, browser, persistence, or evidence authority by themselves. + +PR #168 does not implement Streamable HTTP transport parsing, complete request `_meta` validation, `tools/list` serialization/caching/pagination, OAuth, browser I/O, WebMCP/BiDi/CDP translation, secret delivery, persistence, or a complete OriginWeave Protocol adapter. Those remain separate adapter/runtime work. Protected `main` therefore must continue to describe MCP as planned until this active-PR evidence is integrated, and even after integration only the merged bounded routing foundation may be called implemented; the full adapter remains planned until its remaining acceptance boundaries ship. + +The version boundary is explicit: the routing foundation accepts only MCP `2026-07-28`; it does not infer compatibility with later protocol generations. OriginWeave Protocol versioning remains independent and cannot be changed by MCP metadata. + ## Consequences OriginWeave carries adapter maintenance and version negotiation but gains a durable customer API. Multiple browser/control transports can coexist. New upstream capabilities do not silently change risk or action semantics. Compatibility matrices become release artifacts. @@ -50,13 +58,15 @@ Protocol validation occurs before messages influence policy. Tool/page-provided Require version-negotiation tests, schema/property tests, malformed-message tests, BiDi/CDP semantic parity tests for shared capabilities, WebMCP prompt-injection tests, MCP authority-separation and version-change tests, browser-version compatibility matrices, and end-to-end proof that unsupported capabilities fail without side effects. +For active PR #168 specifically, acceptance additionally requires deterministic tool-name bounds/syntax, exact header/body method and tool-name correlation at the represented routing boundary, explicit unknown-tool rejection, one unambiguous tool-to-action registry, independent capability/risk expectations, route/action mismatch denial before ordinary policy evaluation, exact 100% owned-production coverage, and unchanged-head CI/security/review evidence. These checks do not substitute for complete transport or adapter conformance. + ## Migration and rollback Adapters are independently versioned and can be canaried. Clients migrate through OriginWeave Protocol compatibility rules, not upstream protocol rewrites. Rollback pins a previously supported adapter/browser/protocol pair and records that pair in provenance. ## Open follow-ups -Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, and MCP/WebMCP schema isolation. +Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, complete MCP Streamable HTTP/request-metadata validation, MCP discovery/serialization/cache behavior, and MCP/WebMCP schema isolation. ## Supersession / reversal conditions @@ -68,10 +78,12 @@ Chrome DevTools Protocol. (2026). *Chrome DevTools Protocol — latest (tip-of-t Chrome DevTools Protocol. (2026). *WebMCP domain*. Chromium. Retrieved August 9, 2026, from https://chromedevtools.github.io/devtools-protocol/tot/WebMCP/ +Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://modelcontextprotocol.io/specification/2026-07-28 + 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, June 29). *WebDriver BiDi* [Working Draft]. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260629/ ## Related documents -See `docs/API_CONTRACT.md`, `docs/TRD.md`, `docs/doctoring/product-documentation-baseline.md`, and `docs/DATA_GOVERNANCE.md`. +See `docs/API_CONTRACT.md`, `docs/TRD.md`, `docs/doctoring.md`, `docs/doctoring/product-documentation-baseline.md`, `docs/traceability/README.md`, and `docs/DATA_GOVERNANCE.md`. From f9a1fa9c42c72c76b029f71c56acd8261cff338c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 18:39:03 +0900 Subject: [PATCH 093/570] docs(mcp): clarify active routing foundation --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 17085c05d..a956ff60b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ OriginWeave is a Chromium-compatible, Rust-first control plane for governed AI agents on the web. It is designed to let an agent observe, extract, and act without turning untrusted page content into authority, exposing secrets to a model, connecting to an unapproved network destination, accepting an unauthenticated web service, or losing the evidence required to explain what happened. -> Project status: pre-alpha. The current repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, and authenticated TLS service-identity kernels. Chromium, WebDriver BiDi, CDP, MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. +> Project status: pre-alpha. The current protected repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, and authenticated TLS service-identity kernels. Chromium, WebDriver BiDi, CDP, complete MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. Active PR #168 implements only a bounded MCP `2026-07-28` stateless tool-routing and typed-action/policy foundation; it remains non-shipped active-PR evidence and does not make the complete MCP adapter available. ## Why OriginWeave @@ -40,6 +40,8 @@ The repository is organized as independently consumable Rust crates: - `originweave-resource`: task-level RAM, VRAM, thread, and frame-time budgets with cumulative mitigation plans. - `originweave-evidence`: universally value-redacted network evidence and source-bound provenance records. +Active PR #168 additionally carries a non-shipped `originweave-core` MCP routing registry and `originweave-policy` binding for the MCP `2026-07-28` `tools/call` boundary. That foundation validates and maps an explicit tool name to an existing typed action; it does not implement transport parsing, `tools/list`, OAuth, browser control, secret materialization, persistence, or ambient authority. + See [ARCHITECTURE.md](ARCHITECTURE.md) and the [architecture decision records](docs/adr/) for binding design decisions. ## Safety model @@ -97,7 +99,7 @@ isolated Chromium session → redacted provenance bundle ``` -Subsequent work connects the live Chromium network service, adds explicit proxy and download policy, WARC/PROV persistence, MCP and Browser Agent Protocol adapters, extension compatibility testing, GPU/RAM telemetry, prompt-injection benchmarks, and an accessible approval interface. See [docs/product-roadmap.md](docs/product-roadmap.md). +Subsequent work connects the live Chromium network service, adds explicit proxy and download policy, WARC/PROV persistence, completes the MCP and Browser Agent Protocol adapters beyond the active routing foundation, expands extension compatibility testing, adds GPU/RAM telemetry and prompt-injection benchmarks, and builds an accessible approval interface. See [docs/product-roadmap.md](docs/product-roadmap.md). ## Hourly product-development loop From abfb2612c3e9071709094513ab3c0f8849a740cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 18:40:12 +0900 Subject: [PATCH 094/570] docs(mcp): add 2026-07-28 primary specification --- docs/doctoring.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/doctoring.md b/docs/doctoring.md index f0133bb5d..7bf45f95f 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,6 +8,8 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it 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. + ### Browser origin equivalence The WHATWG URL host parser and Chromium canonicalizer classify shortened decimal, integer, hexadecimal, legacy octal-looking, and mixed-component numeric hosts as IPv4 or broken IPv4 candidates rather than ordinary DNS names. Chromium's regression suite includes values such as `192`, `0xC0a80001`, `030052000001`, and mixed hexadecimal components. A non-final empty `0x` component can participate in Chromium's multi-part IPv4 truncation behavior, but a final `0x` label does not produce an IPv4 number because stripping its prefix leaves no digits; it remains a domain label. Chromium also warns that broken IP-like hosts must not be connected because another resolver could accept them. OriginWeave therefore admits only canonical dotted-decimal IPv4 into its policy origin type, rejects browser-special numeric spellings before DNS validation, and preserves final non-numeric DNS labels such as `0x`. @@ -142,6 +144,8 @@ Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *OAuth 2.0 securi Microsoft. (2025, July 25). *Azure IP address 168.63.129.16 overview*. Microsoft Learn. https://learn.microsoft.com/azure/virtual-network/what-is-ip-address-168-63-129-16 +Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://modelcontextprotocol.io/specification/2026-07-28 + Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). *Learning to orchestrate agents in natural language with the Conductor* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04388 Rescorla, E. (2026). *The Transport Layer Security (TLS) protocol version 1.3* (RFC 9846). Internet Engineering Task Force. https://doi.org/10.17487/RFC9846 From 7d1b610cb215c8f1b32727654972bd0ed17c5280 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 18:40:56 +0900 Subject: [PATCH 095/570] docs(mcp): trace active routing boundary --- docs/traceability/mcp-authority-route.md | 53 ++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/traceability/mcp-authority-route.md diff --git a/docs/traceability/mcp-authority-route.md b/docs/traceability/mcp-authority-route.md new file mode 100644 index 000000000..bea49533a --- /dev/null +++ b/docs/traceability/mcp-authority-route.md @@ -0,0 +1,53 @@ +# MCP 2026-07-28 authority-route traceability + +- **Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` +- **Owning work:** PR #168 `feat/mcp): bind stateless tool routing to typed actions` +- **Protected-main status:** non-shipped active-PR evidence +- **Complete MCP adapter status:** `PLANNED` +- **Governing decision:** ADR 0107 + +## Scope + +PR #168 implements a bounded Rust control-plane foundation for MCP `2026-07-28` `tools/call` routing. It validates the represented stateless routing envelope, bounds and syntax-validates both attacker-controlled tool-name fields before correlation, maps only an explicit reviewed `originweave.*` catalog to existing typed `ActionKind` values, derives discovery metadata from the same catalog, and rejects route/action mismatch before ordinary deterministic policy evaluation. + +A successful `ValidatedMcpToolCall` proves routing integrity only. It grants no capability, origin, approval, secret, browser, tenant, persistence, network, or evidence authority. `originweave_policy::evaluate_mcp` still delegates to the ordinary policy evaluator after the route/action match. + +## Product-status reconciliation + +`docs/PRD.md` PRD-INT-004 and `docs/TRD.md` Section 12 intentionally remain **Planned** at the complete-adapter level. That status is not contradicted by this active PR: the PR implements only a reusable routing/action-policy foundation below the product adapter. `README.md` and `CHANGELOG.md` therefore distinguish the active foundation from shipped protected-main capability, and ADR 0107 records the same version and authority boundary. + +The following remain outside PR #168 and must not be inferred from it: + +- Streamable HTTP transport parsing and header materialization; +- complete request `_meta` validation, including per-request client capabilities; +- `tools/list` serialization, pagination, cache semantics, and subscription handling; +- OAuth and authenticated MCP deployment policy; +- browser-control I/O or BiDi/CDP/WebMCP translation; +- secret materialization or broker transport; +- persistence, durable audit storage, or WARC/PROV export; and +- an OriginWeave Protocol version transition. + +## Version boundary + +The active routing foundation accepts only protocol generation `2026-07-28`. MCP versioning is independent of the OriginWeave Protocol. A later MCP revision does not silently change OriginWeave action, risk, capability, approval, secret, origin, tenant, browser, or evidence semantics. + +The reviewed primary source is: + +Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://modelcontextprotocol.io/specification/2026-07-28 + +The canonical bibliography remains `docs/doctoring.md`. + +## Executable evidence + +Current PR #168 production/test surfaces include: + +- `crates/originweave-core/src/mcp.rs` — bounded deterministic catalog and `ValidatedMcpToolCall` routing primitive; +- `crates/originweave-core/tests/mcp_authority_route.rs` — mapping, bounds, malformed-input, version/method/header-body, and error-contract evidence; +- `crates/originweave-policy/src/lib.rs` — `evaluate_mcp` route/action guard before normal policy evaluation; and +- `crates/originweave-policy/tests/mcp_route_binding.rs` — confused-deputy and policy-preservation evidence. + +Exact current-head CI/security/review evidence must be regenerated after every branch mutation. Predecessor-head success is historical only. + +## Promotion rule + +This dossier may change to `IMPLEMENTED_ON_PROTECTED_MAIN` for the bounded routing foundation only after PR #168 reaches protected `main` under live governance and exact-head acceptance. That promotion still does **not** promote the complete MCP adapter from `PLANNED`; each remaining transport/runtime boundary requires its own integrated evidence. From 3f7daa7923a824b8e2307f824bf272b2947fbbb0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:11:50 +0900 Subject: [PATCH 096/570] test(mcp): reject unissued tools list cursors --- .../tests/mcp_tools_list_cache.rs | 87 ++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/mcp_tools_list_cache.rs b/crates/originweave-core/tests/mcp_tools_list_cache.rs index 54c89e6b3..3a963a74a 100644 --- a/crates/originweave-core/tests/mcp_tools_list_cache.rs +++ b/crates/originweave-core/tests/mcp_tools_list_cache.rs @@ -1,5 +1,9 @@ +use std::error::Error; + use originweave_core::mcp::{ - McpCacheScope, McpResultType, mcp_tools_list_page, supported_mcp_tools, + MCP_PROTOCOL_VERSION, MCP_TOOLS_LIST_METHOD, McpCacheScope, McpResultType, + McpToolsListBoundaryError, ValidatedMcpToolsListRequest, mcp_tools_list_page, + supported_mcp_tools, }; #[test] @@ -12,3 +16,84 @@ fn mcp_tools_list_page_is_complete_private_and_immediately_stale() { assert_eq!(page.cache_scope(), McpCacheScope::Private); assert_eq!(page.next_cursor(), None); } + +#[test] +fn mcp_tools_list_request_requires_exact_routing_and_no_unissued_cursor() { + let request = ValidatedMcpToolsListRequest::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ); + assert_eq!( + request.map(|validated| validated.method()), + Ok(MCP_TOOLS_LIST_METHOD) + ); + + assert_eq!( + ValidatedMcpToolsListRequest::new( + "2025-11-25", + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::UnsupportedProtocolVersion) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_LIST_METHOD, + "tools/call", + None, + ), + Err(McpToolsListBoundaryError::HeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + MCP_PROTOCOL_VERSION, + "resources/list", + "resources/list", + None, + ), + Err(McpToolsListBoundaryError::UnsupportedMethod) + ); + + for cursor in ["cursor-1", ""] { + assert_eq!( + ValidatedMcpToolsListRequest::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + Some(cursor), + ), + Err(McpToolsListBoundaryError::UnsupportedCursor) + ); + } +} + +#[test] +fn mcp_tools_list_request_errors_are_source_free_and_non_echoing() { + let cases = [ + ( + McpToolsListBoundaryError::UnsupportedProtocolVersion, + "unsupported MCP protocol version", + ), + ( + McpToolsListBoundaryError::HeaderBodyMismatch, + "MCP routing headers do not match the request body", + ), + ( + McpToolsListBoundaryError::UnsupportedMethod, + "only MCP tools/list requests can enter the discovery boundary", + ), + ( + McpToolsListBoundaryError::UnsupportedCursor, + "MCP tools/list cursor was not issued by this fixed catalog", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } +} From 927825c90ad1f5299d603f6c12588579c9f1f52e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:15:21 +0900 Subject: [PATCH 097/570] feat(mcp): validate tools list request routing --- crates/originweave-core/src/mcp.rs | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index 72b222ff7..d6d33e155 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -17,6 +17,9 @@ pub const MCP_PROTOCOL_VERSION: &str = "2026-07-28"; /// The only MCP method that can enter the typed action-routing boundary. pub const MCP_TOOLS_CALL_METHOD: &str = "tools/call"; +/// The MCP discovery method accepted by the typed tools-list boundary. +pub const MCP_TOOLS_LIST_METHOD: &str = "tools/list"; + /// Maximum accepted MCP tool-name length in bytes. pub const MAX_MCP_TOOL_NAME_BYTES: usize = 128; @@ -207,6 +210,86 @@ pub const fn mcp_tools_list_page() -> McpToolsListPage { } } +/// A deterministic failure while validating one MCP `tools/list` routing envelope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpToolsListBoundaryError { + /// The request names an MCP protocol generation this boundary does not support. + UnsupportedProtocolVersion, + /// MCP routing metadata disagrees with the method in the body. + HeaderBodyMismatch, + /// The request method is not the supported `tools/list` operation. + UnsupportedMethod, + /// The request supplied a cursor that this fixed single-page catalog never issued. + UnsupportedCursor, +} + +impl fmt::Display for McpToolsListBoundaryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedProtocolVersion => { + formatter.write_str("unsupported MCP protocol version") + } + Self::HeaderBodyMismatch => { + formatter.write_str("MCP routing headers do not match the request body") + } + Self::UnsupportedMethod => formatter + .write_str("only MCP tools/list requests can enter the discovery boundary"), + Self::UnsupportedCursor => formatter + .write_str("MCP tools/list cursor was not issued by this fixed catalog"), + } + } +} + +impl std::error::Error for McpToolsListBoundaryError {} + +/// An MCP `tools/list` request whose protocol and routing envelope were validated. +/// +/// This boundary is deliberately narrower than a general pagination implementation. The current +/// reviewed catalog returns one complete page and emits no continuation cursor, so no non-null +/// cursor can be a value previously issued by OriginWeave. A transport adapter must not silently +/// ignore or reinterpret a supplied cursor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ValidatedMcpToolsListRequest { + method: &'static str, +} + +impl ValidatedMcpToolsListRequest { + /// Validate the stateless routing envelope for the current fixed `tools/list` catalog. + /// + /// The MCP protocol version and routing/body method must match this reviewed boundary. Any + /// supplied cursor fails closed because [`mcp_tools_list_page`] emits no continuation cursor; + /// accepting one would silently invent pagination state that OriginWeave never issued. + pub fn new( + protocol_version: &str, + routing_method: &str, + body_method: &str, + cursor: Option<&str>, + ) -> Result { + if protocol_version != MCP_PROTOCOL_VERSION { + return Err(McpToolsListBoundaryError::UnsupportedProtocolVersion); + } + if routing_method != body_method { + return Err(McpToolsListBoundaryError::HeaderBodyMismatch); + } + if routing_method != MCP_TOOLS_LIST_METHOD { + return Err(McpToolsListBoundaryError::UnsupportedMethod); + } + if cursor.is_some() { + return Err(McpToolsListBoundaryError::UnsupportedCursor); + } + + Ok(Self { + method: MCP_TOOLS_LIST_METHOD, + }) + } + + /// Return the canonical MCP method validated by this request. + #[must_use] + pub const fn method(&self) -> &'static str { + self.method + } +} + /// A deterministic failure while validating untrusted MCP routing metadata. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpToolBoundaryError { From 582eac4dcac21881e12e2784b8a0f746df5c4b6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:19:39 +0900 Subject: [PATCH 098/570] style(mcp): apply canonical rustfmt --- crates/originweave-core/src/mcp.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index d6d33e155..6625c0250 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -232,10 +232,12 @@ impl fmt::Display for McpToolsListBoundaryError { Self::HeaderBodyMismatch => { formatter.write_str("MCP routing headers do not match the request body") } - Self::UnsupportedMethod => formatter - .write_str("only MCP tools/list requests can enter the discovery boundary"), - Self::UnsupportedCursor => formatter - .write_str("MCP tools/list cursor was not issued by this fixed catalog"), + Self::UnsupportedMethod => { + formatter.write_str("only MCP tools/list requests can enter the discovery boundary") + } + Self::UnsupportedCursor => { + formatter.write_str("MCP tools/list cursor was not issued by this fixed catalog") + } } } } From 43cbfcb96c297905d8569a9bee5af94f88db0065 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:21:54 +0900 Subject: [PATCH 099/570] docs(mcp): record tools list request boundary --- docs/doctoring/browser-agent-protocols.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 4bb3a3dce..87cc62b91 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -1,6 +1,6 @@ # Browser and Agent Protocol Standards Evidence -- **Reviewed:** 2026-08-16 +- **Reviewed:** 2026-08-18 - **Purpose:** primary-source evidence for OriginWeave browser compatibility and adapter boundaries - **Canonical research index:** [`../doctoring.md`](../doctoring.md) @@ -38,6 +38,8 @@ Primary sources: Chrome for Developers, *WebMCP*; *WebMCP tool security*; *Agent The Model Context Protocol project released specification version `2026-07-28` on 28 July 2026. That release moved the protocol core toward stateless request/response operation and removed the earlier protocol-session assumptions described by previous releases. OriginWeave therefore keeps durable browser state in explicit OriginWeave application handles and exposes MCP only as a high-level adapter to the Rust runtime. MCP clients or servers do not connect models directly to Chromium/CDP authority. +The Streamable HTTP contract requires `MCP-Protocol-Version` and `Mcp-Method` on modern requests and requires a server that processes the request body to reject mirrored header/body mismatches. `Mcp-Name` is required only for named operations such as `tools/call`, `resources/read`, and `prompts/get`, not `tools/list`. OriginWeave's typed `tools/list` admission boundary therefore validates the exact protocol generation plus routing/body method agreement without inventing a name header. It rejects any supplied cursor because the current fixed catalog emits no `nextCursor`; this is a conservative local invariant against accepting pagination state OriginWeave never issued, not a claim that MCP forbids `tools/list` cursors generally. + The same specification requires every Result to carry `resultType`, using `complete` for a terminal result, and adds explicit cache hints for cacheable result families including `tools/list`: `ttlMs` expresses freshness lifetime and `cacheScope` expresses whether reuse is private or shareable. OriginWeave's first typed `tools/list` result therefore binds `resultType = complete`, chooses the conservative boundary `ttlMs = 0` and private scope, derives the page directly from the reviewed tool catalog, and emits no continuation cursor for the current fixed single-page catalog. These metadata choices do not grant tool authority and do not claim JSON-RPC serialization, transport caching, OAuth, or a general pagination implementation. Primary sources: Model Context Protocol, *2026-07-28 Specification* and the maintainers' official release announcement. @@ -53,9 +55,10 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re 3. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. 4. Keep WebMCP experimental/optional and propagate untrusted-content semantics. 5. Keep MCP browser state application-level rather than equating protocol transport/session metadata with browser authority. -6. Bind mandatory MCP result disposition and cacheable-list metadata to reviewed typed results; use a complete terminal result with zero freshness and private scope unless a separate reviewed policy proves broader semantics safe. -7. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. -8. Treat WARC/PROV as provenance representations, not policy or truth escalation. +6. Validate modern MCP protocol/method routing against the request body before adapter dispatch; require `Mcp-Name` only for operations for which the specification defines it. +7. Bind mandatory MCP result disposition and cacheable-list metadata to reviewed typed results; use a complete terminal result with zero freshness and private scope unless a separate reviewed policy proves broader semantics safe. Reject a `tools/list` cursor while the current fixed page has never issued one. +8. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. +9. Treat WARC/PROV as provenance representations, not policy or truth escalation. ## References — APA 7th @@ -79,4 +82,4 @@ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.o World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ -International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html +International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html \ No newline at end of file From e88550b053dfb9cbf891f44a956eb9dcf896d6d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:22:39 +0900 Subject: [PATCH 100/570] docs(mcp): keep active tools list boundary current --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c80a69de5..255e12b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. - Active PR #168 adds deterministic MCP `2026-07-28` stateless tool-routing foundations with bounded names, a single reviewed tool-to-action registry shared by routing and discovery metadata, and fail-closed policy binding that grants no ambient authority. This is active-PR evidence only; the complete MCP adapter, transport serialization, discovery response handling, OAuth, browser I/O, and persistence remain planned until separately integrated on protected `main`. -- Active PR #170 adds conservative MCP `2026-07-28` `tools/list` discovery metadata derived from the reviewed #168 catalog, with `resultType = complete`, zero freshness, private cache scope, and no continuation cursor. This remains active-PR evidence only and does not claim transport serialization, transport caching, OAuth, general pagination, browser I/O, persistence, or new Agent authority. +- Active PR #170 adds conservative MCP `2026-07-28` `tools/list` discovery metadata derived from the reviewed #168 catalog, with `resultType = complete`, zero freshness, private cache scope, and no continuation cursor, plus a stateless request-admission boundary that requires the exact protocol generation, exact routing/body `tools/list` method agreement, and rejects every supplied cursor while this fixed page has issued none. This remains active-PR evidence only; rejecting an unissued cursor is a local fail-closed invariant, not a claim that MCP forbids pagination, and the slice does not claim transport serialization, transport caching, OAuth, general pagination, browser I/O, persistence, or new Agent authority. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. From 3b0148bc97989f974cb745cf3cd35d16f16a2d06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:35:31 +0900 Subject: [PATCH 101/570] test(mcp): require per-request metadata on tools list --- .../tests/mcp_tools_list_cache.rs | 110 +++++++++++++++--- 1 file changed, 91 insertions(+), 19 deletions(-) diff --git a/crates/originweave-core/tests/mcp_tools_list_cache.rs b/crates/originweave-core/tests/mcp_tools_list_cache.rs index 3a963a74a..d271778f9 100644 --- a/crates/originweave-core/tests/mcp_tools_list_cache.rs +++ b/crates/originweave-core/tests/mcp_tools_list_cache.rs @@ -17,22 +17,64 @@ fn mcp_tools_list_page_is_complete_private_and_immediately_stale() { assert_eq!(page.next_cursor(), None); } -#[test] -fn mcp_tools_list_request_requires_exact_routing_and_no_unissued_cursor() { - let request = ValidatedMcpToolsListRequest::new( - MCP_PROTOCOL_VERSION, +fn valid_tools_list_request( + cursor: Option<&str>, +) -> Result { + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, MCP_TOOLS_LIST_METHOD, MCP_TOOLS_LIST_METHOD, - None, - ); + cursor, + ) +} + +#[test] +fn mcp_tools_list_request_requires_complete_request_metadata() { assert_eq!( - request.map(|validated| validated.method()), + valid_tools_list_request(None).map(|validated| validated.method()), Ok(MCP_TOOLS_LIST_METHOD) ); assert_eq!( ValidatedMcpToolsListRequest::new( - "2025-11-25", + None, + Some(MCP_PROTOCOL_VERSION), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingProtocolVersionHeader) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + None, + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingProtocolVersionMetadata) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some("2025-11-25"), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some("2025-11-25"), + Some("2025-11-25"), + true, MCP_TOOLS_LIST_METHOD, MCP_TOOLS_LIST_METHOD, None, @@ -41,16 +83,35 @@ fn mcp_tools_list_request_requires_exact_routing_and_no_unissued_cursor() { ); assert_eq!( ValidatedMcpToolsListRequest::new( - MCP_PROTOCOL_VERSION, + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + false, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingClientCapabilities) + ); +} + +#[test] +fn mcp_tools_list_request_requires_exact_routing_and_no_unissued_cursor() { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, MCP_TOOLS_LIST_METHOD, "tools/call", None, ), - Err(McpToolsListBoundaryError::HeaderBodyMismatch) + Err(McpToolsListBoundaryError::MethodHeaderBodyMismatch) ); assert_eq!( ValidatedMcpToolsListRequest::new( - MCP_PROTOCOL_VERSION, + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, "resources/list", "resources/list", None, @@ -60,12 +121,7 @@ fn mcp_tools_list_request_requires_exact_routing_and_no_unissued_cursor() { for cursor in ["cursor-1", ""] { assert_eq!( - ValidatedMcpToolsListRequest::new( - MCP_PROTOCOL_VERSION, - MCP_TOOLS_LIST_METHOD, - MCP_TOOLS_LIST_METHOD, - Some(cursor), - ), + valid_tools_list_request(Some(cursor)), Err(McpToolsListBoundaryError::UnsupportedCursor) ); } @@ -74,13 +130,29 @@ fn mcp_tools_list_request_requires_exact_routing_and_no_unissued_cursor() { #[test] fn mcp_tools_list_request_errors_are_source_free_and_non_echoing() { let cases = [ + ( + McpToolsListBoundaryError::MissingProtocolVersionHeader, + "MCP protocol version header is required", + ), + ( + McpToolsListBoundaryError::MissingProtocolVersionMetadata, + "MCP request metadata protocol version is required", + ), + ( + McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch, + "MCP protocol version header does not match request metadata", + ), ( McpToolsListBoundaryError::UnsupportedProtocolVersion, "unsupported MCP protocol version", ), ( - McpToolsListBoundaryError::HeaderBodyMismatch, - "MCP routing headers do not match the request body", + McpToolsListBoundaryError::MissingClientCapabilities, + "MCP request metadata client capabilities are required", + ), + ( + McpToolsListBoundaryError::MethodHeaderBodyMismatch, + "MCP method header does not match the request body", ), ( McpToolsListBoundaryError::UnsupportedMethod, From 728e422bdcb91f2a92e5a26e60258d2b139b203c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:39:38 +0900 Subject: [PATCH 102/570] fix(mcp): bind tools list to per-request metadata --- crates/originweave-core/src/mcp.rs | 72 +++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index 6625c0250..4ac9813c7 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -210,13 +210,21 @@ pub const fn mcp_tools_list_page() -> McpToolsListPage { } } -/// A deterministic failure while validating one MCP `tools/list` routing envelope. +/// A deterministic failure while validating one MCP `tools/list` request envelope. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpToolsListBoundaryError { + /// The transport request omitted the required MCP protocol-version header. + MissingProtocolVersionHeader, + /// The structured request metadata omitted the required MCP protocol version. + MissingProtocolVersionMetadata, + /// The transport protocol version disagrees with the structured request metadata. + ProtocolVersionHeaderBodyMismatch, /// The request names an MCP protocol generation this boundary does not support. UnsupportedProtocolVersion, - /// MCP routing metadata disagrees with the method in the body. - HeaderBodyMismatch, + /// The structured request metadata omitted the required client-capabilities object. + MissingClientCapabilities, + /// MCP routing method metadata disagrees with the method in the request body. + MethodHeaderBodyMismatch, /// The request method is not the supported `tools/list` operation. UnsupportedMethod, /// The request supplied a cursor that this fixed single-page catalog never issued. @@ -226,11 +234,22 @@ pub enum McpToolsListBoundaryError { impl fmt::Display for McpToolsListBoundaryError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::MissingProtocolVersionHeader => { + formatter.write_str("MCP protocol version header is required") + } + Self::MissingProtocolVersionMetadata => { + formatter.write_str("MCP request metadata protocol version is required") + } + Self::ProtocolVersionHeaderBodyMismatch => formatter + .write_str("MCP protocol version header does not match request metadata"), Self::UnsupportedProtocolVersion => { formatter.write_str("unsupported MCP protocol version") } - Self::HeaderBodyMismatch => { - formatter.write_str("MCP routing headers do not match the request body") + Self::MissingClientCapabilities => { + formatter.write_str("MCP request metadata client capabilities are required") + } + Self::MethodHeaderBodyMismatch => { + formatter.write_str("MCP method header does not match the request body") } Self::UnsupportedMethod => { formatter.write_str("only MCP tools/list requests can enter the discovery boundary") @@ -244,34 +263,53 @@ impl fmt::Display for McpToolsListBoundaryError { impl std::error::Error for McpToolsListBoundaryError {} -/// An MCP `tools/list` request whose protocol and routing envelope were validated. +/// An MCP `tools/list` request whose protocol, required metadata, and routing envelope were +/// validated. /// -/// This boundary is deliberately narrower than a general pagination implementation. The current -/// reviewed catalog returns one complete page and emits no continuation cursor, so no non-null -/// cursor can be a value previously issued by OriginWeave. A transport adapter must not silently -/// ignore or reinterpret a supplied cursor. +/// This boundary is deliberately narrower than a general transport or pagination implementation. +/// A trusted structured parser must prove whether the required per-request client-capabilities +/// object was present; this type never accepts its contents as authority. The current reviewed +/// catalog returns one complete page and emits no continuation cursor, so no non-null cursor can +/// be a value previously issued by OriginWeave. A transport adapter must not silently ignore or +/// reinterpret a supplied cursor. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ValidatedMcpToolsListRequest { method: &'static str, } impl ValidatedMcpToolsListRequest { - /// Validate the stateless routing envelope for the current fixed `tools/list` catalog. + /// Validate the stateless request envelope for the current fixed `tools/list` catalog. /// - /// The MCP protocol version and routing/body method must match this reviewed boundary. Any - /// supplied cursor fails closed because [`mcp_tools_list_page`] emits no continuation cursor; - /// accepting one would silently invent pagination state that OriginWeave never issued. + /// Both the required transport protocol-version header and structured request `_meta` + /// protocol version must be present, equal, and exactly [`MCP_PROTOCOL_VERSION`]. A trusted + /// structured parser must also attest that the required `_meta` client-capabilities object was + /// present; its contents grant no OriginWeave authority. The routing/body method must agree. + /// Any supplied cursor fails closed because [`mcp_tools_list_page`] emits no continuation + /// cursor; accepting one would silently invent pagination state that OriginWeave never issued. pub fn new( - protocol_version: &str, + protocol_version_header: Option<&str>, + protocol_version_metadata: Option<&str>, + client_capabilities_present: bool, routing_method: &str, body_method: &str, cursor: Option<&str>, ) -> Result { - if protocol_version != MCP_PROTOCOL_VERSION { + let protocol_version_header = protocol_version_header + .ok_or(McpToolsListBoundaryError::MissingProtocolVersionHeader)?; + let protocol_version_metadata = protocol_version_metadata + .ok_or(McpToolsListBoundaryError::MissingProtocolVersionMetadata)?; + + if protocol_version_header != protocol_version_metadata { + return Err(McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch); + } + if protocol_version_metadata != MCP_PROTOCOL_VERSION { return Err(McpToolsListBoundaryError::UnsupportedProtocolVersion); } + if !client_capabilities_present { + return Err(McpToolsListBoundaryError::MissingClientCapabilities); + } if routing_method != body_method { - return Err(McpToolsListBoundaryError::HeaderBodyMismatch); + return Err(McpToolsListBoundaryError::MethodHeaderBodyMismatch); } if routing_method != MCP_TOOLS_LIST_METHOD { return Err(McpToolsListBoundaryError::UnsupportedMethod); From 552e0faad97a7bb79e839e237db18ca0238166b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:44:25 +0900 Subject: [PATCH 103/570] style(mcp): apply canonical rustfmt --- crates/originweave-core/src/mcp.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index 4ac9813c7..b27e8c15f 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -240,8 +240,9 @@ impl fmt::Display for McpToolsListBoundaryError { Self::MissingProtocolVersionMetadata => { formatter.write_str("MCP request metadata protocol version is required") } - Self::ProtocolVersionHeaderBodyMismatch => formatter - .write_str("MCP protocol version header does not match request metadata"), + Self::ProtocolVersionHeaderBodyMismatch => { + formatter.write_str("MCP protocol version header does not match request metadata") + } Self::UnsupportedProtocolVersion => { formatter.write_str("unsupported MCP protocol version") } From 530bb7718ecfba361e5932e99101e7e08e296080 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:47:18 +0900 Subject: [PATCH 104/570] docs(mcp): record per-request metadata contract --- docs/doctoring/browser-agent-protocols.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 87cc62b91..052ecf2aa 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -38,7 +38,7 @@ Primary sources: Chrome for Developers, *WebMCP*; *WebMCP tool security*; *Agent The Model Context Protocol project released specification version `2026-07-28` on 28 July 2026. That release moved the protocol core toward stateless request/response operation and removed the earlier protocol-session assumptions described by previous releases. OriginWeave therefore keeps durable browser state in explicit OriginWeave application handles and exposes MCP only as a high-level adapter to the Rust runtime. MCP clients or servers do not connect models directly to Chromium/CDP authority. -The Streamable HTTP contract requires `MCP-Protocol-Version` and `Mcp-Method` on modern requests and requires a server that processes the request body to reject mirrored header/body mismatches. `Mcp-Name` is required only for named operations such as `tools/call`, `resources/read`, and `prompts/get`, not `tools/list`. OriginWeave's typed `tools/list` admission boundary therefore validates the exact protocol generation plus routing/body method agreement without inventing a name header. It rejects any supplied cursor because the current fixed catalog emits no `nextCursor`; this is a conservative local invariant against accepting pagination state OriginWeave never issued, not a claim that MCP forbids `tools/list` cursors generally. +The final `2026-07-28` schema requires every client request to carry `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities` in request `_meta`; client capabilities are request-scoped and servers must not infer them from prior requests. `io.modelcontextprotocol/clientInfo` is optional/SHOULD rather than authorization evidence. For Streamable HTTP, `MCP-Protocol-Version` must agree with the body protocol version, `Mcp-Method` is required for every request, and `Mcp-Name` is required only for named operations such as `tools/call`, `resources/read`, and `prompts/get`, not `tools/list`. OriginWeave's typed `tools/list` admission boundary therefore independently requires the transport protocol-version header and body `_meta` protocol version, rejects disagreement or an unsupported generation, requires per-request client-capabilities presence without treating its contents as OriginWeave authority, validates routing/body `tools/list` method agreement, and does not invent a name header. It rejects any supplied cursor because the current fixed catalog emits no `nextCursor`; this is a conservative local invariant against accepting pagination state OriginWeave never issued, not a claim that MCP forbids `tools/list` cursors generally. The same specification requires every Result to carry `resultType`, using `complete` for a terminal result, and adds explicit cache hints for cacheable result families including `tools/list`: `ttlMs` expresses freshness lifetime and `cacheScope` expresses whether reuse is private or shareable. OriginWeave's first typed `tools/list` result therefore binds `resultType = complete`, chooses the conservative boundary `ttlMs = 0` and private scope, derives the page directly from the reviewed tool catalog, and emits no continuation cursor for the current fixed single-page catalog. These metadata choices do not grant tool authority and do not claim JSON-RPC serialization, transport caching, OAuth, or a general pagination implementation. @@ -55,7 +55,7 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re 3. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. 4. Keep WebMCP experimental/optional and propagate untrusted-content semantics. 5. Keep MCP browser state application-level rather than equating protocol transport/session metadata with browser authority. -6. Validate modern MCP protocol/method routing against the request body before adapter dispatch; require `Mcp-Name` only for operations for which the specification defines it. +6. Require modern MCP per-request protocol version and client capabilities from request `_meta`; on Streamable HTTP require the matching protocol-version header and exact method routing, while treating optional client identity metadata as non-authoritative. 7. Bind mandatory MCP result disposition and cacheable-list metadata to reviewed typed results; use a complete terminal result with zero freshness and private scope unless a separate reviewed policy proves broader semantics safe. Reject a `tools/list` cursor while the current fixed page has never issued one. 8. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. 9. Treat WARC/PROV as provenance representations, not policy or truth escalation. From b8ffd57f3a9083deb6ac121ba928e654b0f21f5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:48:07 +0900 Subject: [PATCH 105/570] docs(mcp): align changelog with request metadata --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 255e12b74..ea9dba86b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. - Active PR #168 adds deterministic MCP `2026-07-28` stateless tool-routing foundations with bounded names, a single reviewed tool-to-action registry shared by routing and discovery metadata, and fail-closed policy binding that grants no ambient authority. This is active-PR evidence only; the complete MCP adapter, transport serialization, discovery response handling, OAuth, browser I/O, and persistence remain planned until separately integrated on protected `main`. -- Active PR #170 adds conservative MCP `2026-07-28` `tools/list` discovery metadata derived from the reviewed #168 catalog, with `resultType = complete`, zero freshness, private cache scope, and no continuation cursor, plus a stateless request-admission boundary that requires the exact protocol generation, exact routing/body `tools/list` method agreement, and rejects every supplied cursor while this fixed page has issued none. This remains active-PR evidence only; rejecting an unissued cursor is a local fail-closed invariant, not a claim that MCP forbids pagination, and the slice does not claim transport serialization, transport caching, OAuth, general pagination, browser I/O, persistence, or new Agent authority. +- Active PR #170 adds conservative MCP `2026-07-28` `tools/list` discovery metadata derived from the reviewed #168 catalog, with `resultType = complete`, zero freshness, private cache scope, and no continuation cursor, plus a stateless request-admission boundary that independently requires the transport protocol-version header and per-request `_meta` protocol version, rejects mismatches or unsupported generations, requires per-request client-capabilities presence without treating capability contents as OriginWeave authority, requires exact routing/body `tools/list` method agreement, and rejects every supplied cursor while this fixed page has issued none. This remains active-PR evidence only; client identity metadata is optional/non-authoritative, rejecting an unissued cursor is a local fail-closed invariant rather than a claim that MCP forbids pagination, and the slice does not claim transport serialization, transport caching, OAuth, general pagination, browser I/O, persistence, or new Agent authority. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. From 908a15b363fddf7e53b0195c4966286f01d5b49c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:42:12 -0700 Subject: [PATCH 106/570] test(mcp): bound untrusted method metadata --- .../tests/mcp_authority_route.rs | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/mcp_authority_route.rs b/crates/originweave-core/tests/mcp_authority_route.rs index a1b1b6fc3..4722776e6 100644 --- a/crates/originweave-core/tests/mcp_authority_route.rs +++ b/crates/originweave-core/tests/mcp_authority_route.rs @@ -1,8 +1,8 @@ use std::error::Error; use originweave_core::mcp::{ - MAX_MCP_TOOL_NAME_BYTES, MCP_PROTOCOL_VERSION, MCP_TOOLS_CALL_METHOD, McpToolBoundaryError, - ValidatedMcpToolCall, supported_mcp_tools, + MAX_MCP_METHOD_NAME_BYTES, MAX_MCP_TOOL_NAME_BYTES, MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, McpToolBoundaryError, ValidatedMcpToolCall, supported_mcp_tools, }; use originweave_core::{ActionKind, Capability, RiskClass}; @@ -193,6 +193,54 @@ fn mcp_route_rejects_protocol_header_body_and_method_drift() { ); } +#[test] +fn mcp_route_bounds_each_untrusted_method_before_cross_field_comparison() { + let at_limit = "x".repeat(MAX_MCP_METHOD_NAME_BYTES); + let oversized_routing = "r".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); + let oversized_body = "b".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); + + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + &oversized_routing, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + &oversized_body, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + "tools call", + "originweave.observe", + "tools call", + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + &at_limit, + "originweave.observe", + &at_limit, + "originweave.observe", + ), + Err(McpToolBoundaryError::UnsupportedMethod) + ); +} + #[test] fn mcp_route_rejects_unbounded_malformed_and_unmapped_tool_names() { let at_limit = "x".repeat(MAX_MCP_TOOL_NAME_BYTES); @@ -273,6 +321,10 @@ fn mcp_boundary_errors_are_deterministic_and_do_not_echo_untrusted_values() { McpToolBoundaryError::UnsupportedMethod, "only MCP tools/call requests can enter the typed action boundary", ), + ( + McpToolBoundaryError::InvalidMethod, + "MCP method violates the bounded ASCII routing syntax", + ), ( McpToolBoundaryError::InvalidToolName, "MCP tool name violates the bounded ASCII routing syntax", From b1a5a32f5f8077b99628e561f1ad73d6911fe3f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:43:45 -0700 Subject: [PATCH 107/570] fix(mcp): bound untrusted method metadata --- crates/originweave-core/src/mcp.rs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index dd8d60258..b026d5e61 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -17,6 +17,9 @@ pub const MCP_PROTOCOL_VERSION: &str = "2026-07-28"; /// The only MCP method that can enter the typed action-routing boundary. pub const MCP_TOOLS_CALL_METHOD: &str = "tools/call"; +/// Maximum accepted MCP method-name length in bytes. +pub const MAX_MCP_METHOD_NAME_BYTES: usize = 64; + /// Maximum accepted MCP tool-name length in bytes. pub const MAX_MCP_TOOL_NAME_BYTES: usize = 128; @@ -123,6 +126,8 @@ pub enum McpToolBoundaryError { UnsupportedProtocolVersion, /// MCP routing metadata disagrees with the method or tool name in the body. HeaderBodyMismatch, + /// The request method violates the bounded ASCII MCP routing syntax. + InvalidMethod, /// The request method is not the supported `tools/call` operation. UnsupportedMethod, /// The tool name violates the bounded ASCII MCP routing syntax. @@ -140,6 +145,9 @@ impl fmt::Display for McpToolBoundaryError { Self::HeaderBodyMismatch => { formatter.write_str("MCP routing headers do not match the request body") } + Self::InvalidMethod => { + formatter.write_str("MCP method violates the bounded ASCII routing syntax") + } Self::UnsupportedMethod => formatter .write_str("only MCP tools/call requests can enter the typed action boundary"), Self::InvalidToolName => { @@ -167,9 +175,9 @@ impl ValidatedMcpToolCall { /// Routing integrity is intentionally narrower than authorization. A /// successful value proves only that the untrusted protocol version, /// routing metadata, body method, and body tool name agree with one - /// explicitly supported mapping. Each untrusted tool name is shape-validated - /// before cross-field comparison so malformed or oversized names cannot - /// bypass the bounded routing syntax through mismatch handling. + /// explicitly supported mapping. Each untrusted method and tool name is + /// shape-validated before cross-field comparison so malformed or oversized + /// metadata cannot bypass the bounded routing syntax through mismatch handling. pub fn new( protocol_version: &str, routing_method: &str, @@ -180,6 +188,9 @@ impl ValidatedMcpToolCall { if protocol_version != MCP_PROTOCOL_VERSION { return Err(McpToolBoundaryError::UnsupportedProtocolVersion); } + if !valid_method(routing_method) || !valid_method(body_method) { + return Err(McpToolBoundaryError::InvalidMethod); + } if !valid_tool_name(routing_tool_name) || !valid_tool_name(body_tool_name) { return Err(McpToolBoundaryError::InvalidToolName); } @@ -210,6 +221,15 @@ impl ValidatedMcpToolCall { } } +fn valid_method(method: &str) -> bool { + if method.is_empty() || method.len() > MAX_MCP_METHOD_NAME_BYTES { + return false; + } + method + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/')) +} + fn valid_tool_name(tool_name: &str) -> bool { if tool_name.is_empty() || tool_name.len() > MAX_MCP_TOOL_NAME_BYTES { return false; From 77b30b1d065e09a6ea202dc9fdc9c6e765137d37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:48:21 -0700 Subject: [PATCH 108/570] test(mcp): cover empty method boundary --- .../tests/mcp_authority_route.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/originweave-core/tests/mcp_authority_route.rs b/crates/originweave-core/tests/mcp_authority_route.rs index 4722776e6..80357ec63 100644 --- a/crates/originweave-core/tests/mcp_authority_route.rs +++ b/crates/originweave-core/tests/mcp_authority_route.rs @@ -199,6 +199,26 @@ fn mcp_route_bounds_each_untrusted_method_before_cross_field_comparison() { let oversized_routing = "r".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); let oversized_body = "b".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + "", + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + "", + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); assert_eq!( ValidatedMcpToolCall::new( MCP_PROTOCOL_VERSION, From eb0b3e5af93eb0f1f9931ebd74f2c76cc08511c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:52:09 -0700 Subject: [PATCH 109/570] docs(mcp): record bounded method routing contract --- docs/adr/0107-browser-protocol-adapter-strategy.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0107-browser-protocol-adapter-strategy.md b/docs/adr/0107-browser-protocol-adapter-strategy.md index 1c71105d2..e3c0bf657 100644 --- a/docs/adr/0107-browser-protocol-adapter-strategy.md +++ b/docs/adr/0107-browser-protocol-adapter-strategy.md @@ -36,7 +36,7 @@ MCP version negotiation is independent of the OriginWeave Protocol version. As o ### Current implementation boundary -The complete MCP adapter remains **Planned**. Active PR #168 is narrower **IMPLEMENTED_ON_ACTIVE_PR** evidence inside the Rust control plane: it validates the `2026-07-28` stateless `tools/call` routing envelope presented to this boundary, bounds and syntax-checks both untrusted tool-name fields, derives one of the existing typed `ActionKind` values from a deterministic reviewed registry, exposes discovery metadata from that same registry, and requires the resulting action to pass the ordinary OriginWeave policy evaluator. The catalog and validated route grant no capability, approval, origin, secret, browser, persistence, or evidence authority by themselves. +The complete MCP adapter remains **Planned**. Active PR #168 is narrower **IMPLEMENTED_ON_ACTIVE_PR** evidence inside the Rust control plane: it validates the `2026-07-28` stateless `tools/call` routing envelope presented to this boundary, bounds and syntax-checks both untrusted method fields and both untrusted tool-name fields before cross-field correlation, derives one of the existing typed `ActionKind` values from a deterministic reviewed registry, exposes discovery metadata from that same registry, and requires the resulting action to pass the ordinary OriginWeave policy evaluator. The method boundary accepts only nonempty ASCII method names up to 64 bytes using the reviewed routing alphabet, while the tool-name boundary accepts only nonempty ASCII names up to 128 bytes using its narrower reviewed alphabet. The catalog and validated route grant no capability, approval, origin, secret, browser, persistence, or evidence authority by themselves. PR #168 does not implement Streamable HTTP transport parsing, complete request `_meta` validation, `tools/list` serialization/caching/pagination, OAuth, browser I/O, WebMCP/BiDi/CDP translation, secret delivery, persistence, or a complete OriginWeave Protocol adapter. Those remain separate adapter/runtime work. Protected `main` therefore must continue to describe MCP as planned until this active-PR evidence is integrated, and even after integration only the merged bounded routing foundation may be called implemented; the full adapter remains planned until its remaining acceptance boundaries ship. @@ -52,13 +52,13 @@ Adapter negotiation failure disables only affected capabilities. Unsupported or ## Security / privacy / governance impact -Protocol validation occurs before messages influence policy. Tool/page-provided strings remain untrusted. Secret handles never become raw secret protocol payloads; only the separately authorized trusted broker-to-browser delivery path may materialize the value, and that value does not pass through MCP, WebMCP, BiDi observation, or model-visible CDP output. Adapter version/provenance is recorded for audit and incident reconstruction. +Protocol validation occurs before messages influence policy. Tool/page-provided strings remain untrusted. Method and tool routing metadata is shape-bounded before correlation, preventing malformed or oversized untrusted routing strings from being reinterpreted through mismatch handling. Secret handles never become raw secret protocol payloads; only the separately authorized trusted broker-to-browser delivery path may materialize the value, and that value does not pass through MCP, WebMCP, BiDi observation, or model-visible CDP output. Adapter version/provenance is recorded for audit and incident reconstruction. ## Tests and acceptance evidence Require version-negotiation tests, schema/property tests, malformed-message tests, BiDi/CDP semantic parity tests for shared capabilities, WebMCP prompt-injection tests, MCP authority-separation and version-change tests, browser-version compatibility matrices, and end-to-end proof that unsupported capabilities fail without side effects. -For active PR #168 specifically, acceptance additionally requires deterministic tool-name bounds/syntax, exact header/body method and tool-name correlation at the represented routing boundary, explicit unknown-tool rejection, one unambiguous tool-to-action registry, independent capability/risk expectations, route/action mismatch denial before ordinary policy evaluation, exact 100% owned-production coverage, and unchanged-head CI/security/review evidence. These checks do not substitute for complete transport or adapter conformance. +For active PR #168 specifically, acceptance additionally requires deterministic method and tool-name bounds/syntax, exact header/body method and tool-name correlation only after both sides are bounded, explicit invalid-method/invalid-tool-name/unknown-tool rejection, one unambiguous tool-to-action registry, independent capability/risk expectations, route/action mismatch denial before ordinary policy evaluation, exact 100% owned-production coverage, and unchanged-head CI/security/review evidence. These checks do not substitute for complete transport or adapter conformance. ## Migration and rollback From ecc01e821c99f4b002153e725f20ddd17c58df3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:52:34 -0700 Subject: [PATCH 110/570] docs(mcp): trace bounded method metadata --- docs/traceability/mcp-authority-route.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/traceability/mcp-authority-route.md b/docs/traceability/mcp-authority-route.md index bea49533a..ddbd5927c 100644 --- a/docs/traceability/mcp-authority-route.md +++ b/docs/traceability/mcp-authority-route.md @@ -1,14 +1,14 @@ # MCP 2026-07-28 authority-route traceability - **Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` -- **Owning work:** PR #168 `feat/mcp): bind stateless tool routing to typed actions` +- **Owning work:** PR #168 `feat(mcp): bind stateless tool routing to typed actions` - **Protected-main status:** non-shipped active-PR evidence - **Complete MCP adapter status:** `PLANNED` - **Governing decision:** ADR 0107 ## Scope -PR #168 implements a bounded Rust control-plane foundation for MCP `2026-07-28` `tools/call` routing. It validates the represented stateless routing envelope, bounds and syntax-validates both attacker-controlled tool-name fields before correlation, maps only an explicit reviewed `originweave.*` catalog to existing typed `ActionKind` values, derives discovery metadata from the same catalog, and rejects route/action mismatch before ordinary deterministic policy evaluation. +PR #168 implements a bounded Rust control-plane foundation for MCP `2026-07-28` `tools/call` routing. It validates the represented stateless routing envelope, bounds and syntax-validates both attacker-controlled method fields and both attacker-controlled tool-name fields before correlation, maps only an explicit reviewed `originweave.*` catalog to existing typed `ActionKind` values, derives discovery metadata from the same catalog, and rejects route/action mismatch before ordinary deterministic policy evaluation. Methods are nonempty reviewed-ASCII routing tokens of at most 64 bytes; tool names are nonempty reviewed-ASCII identifiers of at most 128 bytes. Invalid method metadata is rejected distinctly from a bounded but unsupported MCP method. A successful `ValidatedMcpToolCall` proves routing integrity only. It grants no capability, origin, approval, secret, browser, tenant, persistence, network, or evidence authority. `originweave_policy::evaluate_mcp` still delegates to the ordinary policy evaluator after the route/action match. @@ -41,8 +41,8 @@ The canonical bibliography remains `docs/doctoring.md`. Current PR #168 production/test surfaces include: -- `crates/originweave-core/src/mcp.rs` — bounded deterministic catalog and `ValidatedMcpToolCall` routing primitive; -- `crates/originweave-core/tests/mcp_authority_route.rs` — mapping, bounds, malformed-input, version/method/header-body, and error-contract evidence; +- `crates/originweave-core/src/mcp.rs` — bounded deterministic catalog plus method/tool routing validation in the `ValidatedMcpToolCall` primitive; +- `crates/originweave-core/tests/mcp_authority_route.rs` — mapping, exact method/tool bounds, empty/oversized/malformed inputs, version/method/header-body correlation, and error-contract evidence; - `crates/originweave-policy/src/lib.rs` — `evaluate_mcp` route/action guard before normal policy evaluation; and - `crates/originweave-policy/tests/mcp_route_binding.rs` — confused-deputy and policy-preservation evidence. From 535e4a2b707a04982a96ae6b606f9aedf5d13991 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 17:36:19 -0700 Subject: [PATCH 111/570] fix(tls): drop stale removed hash-width test --- crates/originweave-tls/src/trust.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/crates/originweave-tls/src/trust.rs b/crates/originweave-tls/src/trust.rs index 0bc3a0ce7..32aa66e17 100644 --- a/crates/originweave-tls/src/trust.rs +++ b/crates/originweave-tls/src/trust.rs @@ -146,17 +146,3 @@ pub(crate) fn sha256_identifier(bytes: &[u8]) -> String { } identifier } - -#[cfg(test)] -mod tests { - use super::canonical_u64_bytes; - - #[test] - fn trust_bundle_hash_lengths_use_fixed_eight_byte_encoding() { - assert_eq!(canonical_u64_bytes(1), [0, 0, 0, 0, 0, 0, 0, 1]); - assert_eq!( - canonical_u64_bytes(0x0102_0304), - [0, 0, 0, 0, 1, 2, 3, 4] - ); - } -} From 188a4bf32c2bea6af0e4015dcf15006336a4f5b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 05:05:08 -0700 Subject: [PATCH 112/570] ci: refresh Rust branch coverage nightly --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f804f7496..95c2fa1d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,13 +73,13 @@ jobs: persist-credentials: false - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 with: - toolchain: nightly-2026-08-01 + toolchain: nightly-2026-08-18 components: llvm-tools-preview - name: Install pinned cargo-llvm-cov run: cargo +1.97.1 install cargo-llvm-cov --version 0.8.6 --locked - name: Measure production functions, lines, regions, and branches run: >- - cargo +nightly-2026-08-01 llvm-cov + cargo +nightly-2026-08-18 llvm-cov --locked --workspace --all-features @@ -88,7 +88,7 @@ jobs: --output-path coverage.json - name: Record uncovered production lines run: >- - cargo +nightly-2026-08-01 llvm-cov report + cargo +nightly-2026-08-18 llvm-cov report --branch --text --show-missing-lines From bb303b70f5a97bdfaebeea4ed9ae96143fd9e47e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 05:05:23 -0700 Subject: [PATCH 113/570] ci: track the pinned Rust toolchain --- .github/dependabot.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..d331df5fd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "rust-toolchain" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 1 From 94aa3781275a959e7900d13a0d3cd168f11651f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 05:05:53 -0700 Subject: [PATCH 114/570] test: lock Rust toolchain freshness contracts --- tests/test_rust_toolchain_contract.py | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/test_rust_toolchain_contract.py diff --git a/tests/test_rust_toolchain_contract.py b/tests/test_rust_toolchain_contract.py new file mode 100644 index 000000000..f60867e31 --- /dev/null +++ b/tests/test_rust_toolchain_contract.py @@ -0,0 +1,39 @@ +"""Regression contracts for the reproducible Rust compiler baseline.""" + +from __future__ import annotations + +import tomllib +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +RUST_TOOLCHAIN = REPOSITORY_ROOT / "rust-toolchain.toml" +CI_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "ci.yml" +DEPENDABOT = REPOSITORY_ROOT / ".github" / "dependabot.yml" + + +class RustToolchainContractTests(unittest.TestCase): + """Keep stable builds reproducible and branch coverage intentionally fresh.""" + + def test_stable_toolchain_is_exact_and_automatically_tracked(self) -> None: + """The stable compiler changes only through a reviewable manifest update.""" + + manifest = tomllib.loads(RUST_TOOLCHAIN.read_text(encoding="utf-8")) + self.assertEqual(manifest["toolchain"]["channel"], "1.97.1") + + dependabot = DEPENDABOT.read_text(encoding="utf-8") + self.assertIn('package-ecosystem: "rust-toolchain"', dependabot) + self.assertIn('directory: "/"', dependabot) + self.assertIn('interval: "weekly"', dependabot) + + def test_branch_coverage_uses_one_current_date_pinned_nightly(self) -> None: + """Every branch-coverage command uses the same reviewed nightly snapshot.""" + + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + self.assertEqual(workflow.count("nightly-2026-08-18"), 3) + self.assertNotIn("nightly-2026-08-01", workflow) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From 824bd81d6cceb7a2322cdaf4899c96828e3b4721 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 05:06:17 -0700 Subject: [PATCH 115/570] docs: record Rust toolchain freshness policy --- docs/doctoring/rust-toolchain-freshness.md | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/doctoring/rust-toolchain-freshness.md diff --git a/docs/doctoring/rust-toolchain-freshness.md b/docs/doctoring/rust-toolchain-freshness.md new file mode 100644 index 000000000..de566637e --- /dev/null +++ b/docs/doctoring/rust-toolchain-freshness.md @@ -0,0 +1,43 @@ +# Rust toolchain freshness and reproducibility + +## Decision + +OriginWeave keeps Rust `1.97.1` as the exact stable compiler baseline. As of +2026-08-19 this is the current stable point release, so the generic compiler +suggestion to upgrade does not justify replacing it with a floating `stable` +channel. + +Production line, region, and function coverage remains on the stable compiler. +Branch coverage uses the independently date-pinned `nightly-2026-08-18` +toolchain because upstream `cargo-llvm-cov` still identifies Rust branch +coverage as unstable and nightly-only. Every branch-coverage command must use +the same date pin, and exact-head CI must prove that `llvm-tools-preview`, the +pinned `cargo-llvm-cov` release, the workspace, and the coverage verifier remain +compatible before merge. + +The root `rust-toolchain.toml` is tracked through GitHub Dependabot's +`rust-toolchain` ecosystem. Toolchain changes therefore arrive as reviewable +pull requests rather than silently changing underneath local or CI builds. +Date-pinned branch-coverage nightly updates remain explicit infrastructure +changes and must preserve the repository contract test. + +## Failure interpretation + +The historical OriginWeave coverage failure at PR #192 predecessor head +`ccb7d31dfe7654bab800d463c2391cc1a19c7d74` was not proof that the compiler was +too old. The compiler emitted the generic note while rejecting a non-stable +const conversion in test code. The current PR #192 head moved that conversion +out of a constant and passed the complete native CI workflow. Toolchain +freshness and source compatibility are therefore maintained as separate +controls. + +## References + +GitHub. (2026). *Dependabot supports updates for Rust toolchains*. GitHub +Changelog. https://github.blog/changelog/ + +Rust Project Developers. (2026, July 16). *Announcing Rust 1.97.1*. Rust Blog. +https://blog.rust-lang.org/2026/07/16/Rust-1.97.1/ + +Taiki Endo and contributors. (2026). *cargo-llvm-cov* (Version 0.8.6) +[Computer software]. GitHub. https://github.com/taiki-e/cargo-llvm-cov From cab590d713fbba6dfd50d975c9a8370d79053d3b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 06:38:43 -0700 Subject: [PATCH 116/570] ci: apply the reviewed nightly pin to autonomous development --- .../workflows/apply-rust-nightly-refresh.yml | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/apply-rust-nightly-refresh.yml diff --git a/.github/workflows/apply-rust-nightly-refresh.yml b/.github/workflows/apply-rust-nightly-refresh.yml new file mode 100644 index 000000000..0f005979a --- /dev/null +++ b/.github/workflows/apply-rust-nightly-refresh.yml @@ -0,0 +1,58 @@ +name: Apply Rust nightly refresh once + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + refresh-owned-branch: + if: >- + github.repository == 'ContextualWisdomLab/OriginWeave' && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'agent/rust-toolchain-refresh-2026-08-19' && + github.event.pull_request.user.login == 'seonghobae' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + persist-credentials: true + - name: Replace only the reviewed nightly snapshot + env: + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" + python3 - <<'PY' + from pathlib import Path + + path = Path('.github/workflows/hourly-product-development.yml') + source = path.read_text(encoding='utf-8') + old = 'nightly-2026-08-01' + new = 'nightly-2026-08-18' + count = source.count(old) + if count == 0: + print('Autonomous development workflow already uses the reviewed nightly.') + else: + path.write_text(source.replace(old, new), encoding='utf-8') + print(f'Replaced {count} exact nightly selector(s).') + PY + git diff --check + if git diff --quiet -- .github/workflows/hourly-product-development.yml; then + exit 0 + fi + changed="$(git diff --name-only)" + test "$changed" = ".github/workflows/hourly-product-development.yml" + git config user.name "Seongho Bae" + git config user.email "me@seonghobae.me" + git add .github/workflows/hourly-product-development.yml + git commit -m "ci: refresh autonomous Rust nightly" + git push origin "HEAD:${HEAD_BRANCH}" From deac81f35718aea1fbd4303ce1da310c94fa6f09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 06:40:24 -0700 Subject: [PATCH 117/570] ci: materialize autonomous nightly refresh artifact --- .../workflows/apply-rust-nightly-refresh.yml | 52 ++++++++----------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/.github/workflows/apply-rust-nightly-refresh.yml b/.github/workflows/apply-rust-nightly-refresh.yml index 0f005979a..f393f6889 100644 --- a/.github/workflows/apply-rust-nightly-refresh.yml +++ b/.github/workflows/apply-rust-nightly-refresh.yml @@ -1,4 +1,4 @@ -name: Apply Rust nightly refresh once +name: Materialize Rust nightly refresh once on: pull_request: @@ -8,7 +8,7 @@ permissions: contents: read jobs: - refresh-owned-branch: + materialize-owned-branch: if: >- github.repository == 'ContextualWisdomLab/OriginWeave' && github.event.pull_request.head.repo.full_name == github.repository && @@ -16,43 +16,35 @@ jobs: github.event.pull_request.user.login == 'seonghobae' runs-on: ubuntu-24.04 timeout-minutes: 10 - permissions: - contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 0 - persist-credentials: true - - name: Replace only the reviewed nightly snapshot - env: - EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} - HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - name: Materialize only the reviewed nightly snapshot run: | set -euo pipefail - test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" python3 - <<'PY' from pathlib import Path - path = Path('.github/workflows/hourly-product-development.yml') - source = path.read_text(encoding='utf-8') + source_path = Path('.github/workflows/hourly-product-development.yml') + source = source_path.read_text(encoding='utf-8') old = 'nightly-2026-08-01' new = 'nightly-2026-08-18' count = source.count(old) - if count == 0: - print('Autonomous development workflow already uses the reviewed nightly.') - else: - path.write_text(source.replace(old, new), encoding='utf-8') - print(f'Replaced {count} exact nightly selector(s).') + if count != 2: + raise SystemExit(f'expected exactly 2 predecessor selectors, found {count}') + output = Path('nightly-refresh-artifact/hourly-product-development.yml') + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(source.replace(old, new), encoding='utf-8') + refreshed = output.read_text(encoding='utf-8') + if old in refreshed or refreshed.count(new) < 2: + raise SystemExit('nightly refresh artifact failed its replacement contract') PY - git diff --check - if git diff --quiet -- .github/workflows/hourly-product-development.yml; then - exit 0 - fi - changed="$(git diff --name-only)" - test "$changed" = ".github/workflows/hourly-product-development.yml" - git config user.name "Seongho Bae" - git config user.email "me@seonghobae.me" - git add .github/workflows/hourly-product-development.yml - git commit -m "ci: refresh autonomous Rust nightly" - git push origin "HEAD:${HEAD_BRANCH}" + - name: Upload exact refreshed workflow + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: hourly-rust-nightly-${{ github.event.pull_request.head.sha }} + path: nightly-refresh-artifact/hourly-product-development.yml + if-no-files-found: error + retention-days: 1 From a315a87f72b6e3e85914ed3d4bdf7d6118de5042 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:11:37 -0700 Subject: [PATCH 118/570] test(network): require bounded BiDi WebSocket opening write --- .../webdriver_bidi_websocket_opening_write.rs | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs new file mode 100644 index 000000000..994cd6980 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs @@ -0,0 +1,164 @@ +use std::{ + io::{self, Read}, + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketOpeningWriteError, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted explicit target") + }; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + unreachable!("asserted connection plan") + }; + let connection = plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + unreachable!("asserted loopback connection") + }; + connection +} + +fn read_opening_request(mut stream: std::net::TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + Ok(request) +} + +#[test] +fn bounded_opening_write_sends_exact_request_and_preserves_transport_evidence() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || { + let accepted = listener.accept()?; + read_opening_request(accepted.0) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let expected = format!( + "GET /session/{SESSION_ID} HTTP/1.1\r\nHost: {local_addr}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {RFC6455_SAMPLE_KEY}\r\nSec-WebSocket-Version: 13\r\n\r\n" + ); + let write_timeout = Duration::from_millis(500); + let written = plan.write_opening_request(write_timeout); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + + assert_eq!(written.request_byte_count(), expected.len()); + assert_eq!(written.write_timeout(), write_timeout); + assert_eq!(written.client_key().as_str(), RFC6455_SAMPLE_KEY); + assert_eq!(written.transport_evidence().verified_peer().socket_addr(), local_addr); + assert_eq!(written.transport_evidence().verified_peer().session_id(), SESSION_ID); + assert_eq!(written.transport_evidence().attempt_number(), 1); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(received) = server_result { + assert!(received.is_ok(), "{received:?}"); + if let Ok(received) = received { + assert_eq!(received, expected.as_bytes()); + } + } +} + +#[test] +fn opening_write_rejects_zero_and_excessive_deadlines_before_success_evidence() { + for timeout in [ + Duration::ZERO, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT + Duration::from_nanos(1), + ] { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + continue; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + continue; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + continue; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + continue; + }; + + let result = plan.write_opening_request(timeout); + assert!(matches!( + result, + Err(WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }) if write_timeout == timeout + )); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } + } +} From b09318363062ef9de7ffb8e49cf9bd3bc5f70062 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:14:12 -0700 Subject: [PATCH 119/570] feat(network): bound BiDi WebSocket opening write --- .../src/webdriver_bidi_websocket_handshake.rs | 451 +++++++++++++++++- 1 file changed, 448 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 6e896800d..e6b86aa27 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -1,11 +1,23 @@ -use std::fmt; +use std::{ + error::Error, + fmt, + io::{self, Write}, + net::TcpStream, + time::{Duration, Instant}, +}; use originweave_core::VerifiedWebDriverBiDiSocketPeer; -use crate::WebDriverBiDiTcpConnection; +use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; +/// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. +/// +/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. The request is +/// already bounded before this budget is applied. Callers may choose any smaller nonzero deadline. +pub const MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT: Duration = Duration::from_secs(5); + fn is_base64_data_byte(byte: u8) -> bool { byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') } @@ -41,7 +53,7 @@ impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { } } -impl std::error::Error for WebDriverBiDiWebSocketHandshakeError {} +impl Error for WebDriverBiDiWebSocketHandshakeError {} /// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. /// @@ -130,4 +142,437 @@ impl WebDriverBiDiWebSocketHandshakePlan { pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { self.connection.verified_peer() } + + /// Write the complete bounded opening request on the exact verified stream within one deadline. + /// + /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an + /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the + /// destination, or retries after any other I/O failure. A partial write that cannot finish before + /// the same monotonic deadline is an error and yields no successful handoff. Success preserves + /// the live stream, exact transport evidence, and client key for a separately reviewed server + /// handshake validator. It does not read or validate the server response and therefore does not + /// establish WebSocket protocol state or browser/Agent authority. + pub fn write_opening_request( + self, + write_timeout: Duration, + ) -> Result + { + if write_timeout.is_zero() || write_timeout > MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT { + return Err(WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }); + } + + let Self { + connection, + client_key, + request, + } = self; + let (mut stream, transport_evidence) = connection.into_parts(); + let request_byte_count = write_request_with_clock( + &mut stream, + &request, + write_timeout, + Instant::now, + )?; + + Ok(WebDriverBiDiWebSocketOpeningRequestSent { + stream, + transport_evidence, + client_key, + request_byte_count, + write_timeout, + }) + } +} + +/// A live verified stream after the complete client opening request has been written. +/// +/// This state proves only that the exact bounded RFC 6455 client request reached the operating +/// system's verified TCP stream before the configured deadline. It deliberately does not claim that +/// the peer returned `101 Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket +/// is established, or that the peer is the expected Chromium/ChromeDriver process. Those remain +/// separate fail-closed boundaries. +pub struct WebDriverBiDiWebSocketOpeningRequestSent { + pub(crate) stream: TcpStream, + transport_evidence: WebDriverBiDiTcpConnectionEvidence, + client_key: WebDriverBiDiWebSocketClientKey, + request_byte_count: usize, + write_timeout: Duration, +} + +impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") + .field("stream_local_addr", &self.stream.local_addr().ok()) + .field("transport_evidence", &self.transport_evidence) + .field("client_key", &"") + .field("request_byte_count", &self.request_byte_count) + .field("write_timeout", &self.write_timeout) + .finish() + } +} + +impl WebDriverBiDiWebSocketOpeningRequestSent { + /// Borrow the exact verified transport evidence retained with this live stream. + #[must_use] + pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { + &self.transport_evidence + } + + /// Borrow the exact client key required to validate the later server accept value. + #[must_use] + pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { + &self.client_key + } + + /// Return the exact number of opening-request bytes written before success was emitted. + #[must_use] + pub const fn request_byte_count(&self) -> usize { + self.request_byte_count + } + + /// Return the total write deadline configured for this opening request. + #[must_use] + pub const fn write_timeout(&self) -> Duration { + self.write_timeout + } +} + +/// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. +#[derive(Debug)] +pub enum WebDriverBiDiWebSocketOpeningWriteError { + /// The requested total write deadline was zero or above the reviewed resource ceiling. + InvalidWriteTimeout { + /// Rejected caller-supplied deadline. + write_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The monotonic total write deadline elapsed before the complete request was written. + WriteDeadlineExceeded { + /// Number of request bytes written before the deadline elapsed. + bytes_written: usize, + }, + /// Applying the remaining operating-system write timeout failed. + WriteTimeoutConfigurationFailed { + /// Number of request bytes already written before configuration failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket write reported timeout or would-block before completion. + WriteTimedOut { + /// Number of request bytes written before the timed-out operation. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A socket write returned zero bytes before the request was complete. + WriteZero { + /// Number of request bytes written before the zero-length write. + bytes_written: usize, + }, + /// A non-recoverable socket write failed before the complete request was emitted. + WriteFailed { + /// Number of request bytes written before the failure. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidWriteTimeout { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound", + ), + Self::WriteDeadlineExceeded { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write exceeded its monotonic deadline", + ), + Self::WriteTimeoutConfigurationFailed { .. } => formatter.write_str( + "failed to configure the bounded WebDriver BiDi WebSocket opening write timeout", + ), + Self::WriteTimedOut { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timed out before the request was complete", + ), + Self::WriteZero { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write returned zero before the request was complete", + ), + Self::WriteFailed { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write failed before the request was complete", + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketOpeningWriteError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::WriteTimeoutConfigurationFailed { source, .. } + | Self::WriteTimedOut { source, .. } + | Self::WriteFailed { source, .. } => Some(source), + Self::InvalidWriteTimeout { .. } + | Self::WriteDeadlineExceeded { .. } + | Self::WriteZero { .. } => None, + } + } +} + +trait OpeningRequestWriter { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; +} + +impl OpeningRequestWriter for TcpStream { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()> { + TcpStream::set_write_timeout(self, Some(timeout)) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_request_with_clock( + writer: &mut W, + request: &[u8], + write_timeout: Duration, + mut now: N, +) -> Result +where + W: OpeningRequestWriter, + N: FnMut() -> Instant, +{ + let deadline = now() + write_timeout; + let mut bytes_written = 0; + + while bytes_written < request.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written, + }); + } + writer.set_write_timeout(remaining).map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written, + source, + } + })?; + + match writer.write_request_bytes(&request[bytes_written..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { + bytes_written, + }); + } + Ok(count) => bytes_written += count, + Err(source) if source.kind() == io::ErrorKind::Interrupted => {} + Err(source) + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written, + source, + }); + } + Err(source) => { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written, + source, + }); + } + } + } + + Ok(bytes_written) +} + +#[cfg(test)] +mod opening_write_tests { + use super::*; + use std::{collections::VecDeque, error::Error as _}; + + #[derive(Debug)] + enum WriteAction { + Count(usize), + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeWriter { + timeout_error: Option, + actions: VecDeque, + configured_timeouts: Vec, + } + + impl FakeWriter { + fn new(actions: impl IntoIterator) -> Self { + Self { + timeout_error: None, + actions: actions.into_iter().collect(), + configured_timeouts: Vec::new(), + } + } + } + + impl OpeningRequestWriter for FakeWriter { + fn set_write_timeout(&self, _timeout: Duration) -> io::Result<()> { + if let Some(kind) = self.timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + let action = self.actions.pop_front().unwrap_or(WriteAction::Count(bytes.len())); + match action { + WriteAction::Count(count) => Ok(count.min(bytes.len())), + WriteAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + #[test] + fn bounded_writer_completes_partial_and_interrupted_writes() { + let mut writer = FakeWriter::new([ + WriteAction::Count(2), + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(3), + ]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start, start]); + let result = write_request_with_clock( + &mut writer, + b"hello", + Duration::from_secs(1), + || times.pop_front().unwrap_or(start), + ); + assert_eq!(result, Ok(5)); + } + + #[test] + fn bounded_writer_classifies_deadline_timeout_zero_and_io_failures() { + let start = Instant::now(); + + let mut deadline_writer = FakeWriter::new([]); + let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); + let deadline = write_request_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + || deadline_times.pop_front().unwrap_or(start), + ); + assert!(matches!( + deadline, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 0 + }) + )); + + let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); + let zero = write_request_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + || start, + ); + assert!(matches!( + zero, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) + )); + + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut writer = FakeWriter::new([WriteAction::Error(kind)]); + let timed_out = write_request_with_clock( + &mut writer, + b"x", + Duration::from_secs(1), + || start, + ); + assert!(matches!( + timed_out, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 0, + .. + }) + )); + } + + let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let failed = write_request_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + || start, + ); + assert!(matches!( + failed, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + .. + }) + )); + + let mut configuration_writer = FakeWriter::new([]); + configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let configuration = write_request_with_clock( + &mut configuration_writer, + b"x", + Duration::from_secs(1), + || start, + ); + assert!(matches!( + configuration, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + .. + }) + )); + } + + #[test] + fn opening_write_errors_have_deterministic_messages_and_sources() { + let invalid = WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }; + let deadline = WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 1, + }; + let configure = + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }; + let zero = WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 }; + let failed = WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }; + + assert!(!invalid.to_string().is_empty()); + assert!(!deadline.to_string().is_empty()); + assert!(!configure.to_string().is_empty()); + assert!(!timed_out.to_string().is_empty()); + assert!(!zero.to_string().is_empty()); + assert!(!failed.to_string().is_empty()); + assert!(invalid.source().is_none()); + assert!(deadline.source().is_none()); + assert!(configure.source().is_some()); + assert!(timed_out.source().is_some()); + assert!(zero.source().is_none()); + assert!(failed.source().is_some()); + } } From 15356dd07d8ffd5cb23ebd145a7ec7817f406154 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:14:26 -0700 Subject: [PATCH 120/570] feat(network): export bounded WebSocket opening write state --- crates/originweave-network/src/lib.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index a77d9b794..289dc4681 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -4,8 +4,9 @@ //! without hostname resolution or proxy inheritance, verifies operating-system //! peers before exposing transport I/O, and emits credential-free evidence. //! It also bridges a session-correlated WebDriver BiDi loopback target from -//! `originweave-core` into one bounded exact TCP connection and can bind an inert -//! RFC 6455 opening request to an already-verified plain BiDi stream without +//! `originweave-core` into one bounded exact TCP connection, binds an RFC 6455 +//! opening request to that verified plain stream, and can write that exact request +//! under one bounded deadline without claiming a completed WebSocket handshake or //! granting browser, WebSocket, TLS, policy, or Agent authority. #![forbid(unsafe_code)] @@ -25,5 +26,6 @@ pub use webdriver_bidi_connection::{ }; pub use webdriver_bidi_websocket_handshake::{ WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakeError, - WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketOpeningRequestSent, + WebDriverBiDiWebSocketOpeningWriteError, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, }; From e88a4a5b564421086e77f72292b307943a3869e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:18:18 -0700 Subject: [PATCH 121/570] style(network): apply canonical Rust formatting --- .../src/webdriver_bidi_websocket_handshake.rs | 110 ++++++++---------- 1 file changed, 47 insertions(+), 63 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index e6b86aa27..aa4d5cc96 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -158,10 +158,12 @@ impl WebDriverBiDiWebSocketHandshakePlan { ) -> Result { if write_timeout.is_zero() || write_timeout > MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT { - return Err(WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { - write_timeout, - maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, - }); + return Err( + WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }, + ); } let Self { @@ -170,12 +172,8 @@ impl WebDriverBiDiWebSocketHandshakePlan { request, } = self; let (mut stream, transport_evidence) = connection.into_parts(); - let request_byte_count = write_request_with_clock( - &mut stream, - &request, - write_timeout, - Instant::now, - )?; + let request_byte_count = + write_request_with_clock(&mut stream, &request, write_timeout, Instant::now)?; Ok(WebDriverBiDiWebSocketOpeningRequestSent { stream, @@ -208,7 +206,10 @@ impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") .field("stream_local_addr", &self.stream.local_addr().ok()) .field("transport_evidence", &self.transport_evidence) - .field("client_key", &"") + .field( + "client_key", + &"", + ) .field("request_byte_count", &self.request_byte_count) .field("write_timeout", &self.write_timeout) .finish() @@ -353,9 +354,9 @@ where while bytes_written < request.len() { let remaining = deadline.saturating_duration_since(now()); if remaining.is_zero() { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written, - }); + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, + ); } writer.set_write_timeout(remaining).map_err(|source| { WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { @@ -366,9 +367,7 @@ where match writer.write_request_bytes(&request[bytes_written..]) { Ok(0) => { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { - bytes_written, - }); + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); } Ok(count) => bytes_written += count, Err(source) if source.kind() == io::ErrorKind::Interrupted => {} @@ -410,7 +409,6 @@ mod opening_write_tests { struct FakeWriter { timeout_error: Option, actions: VecDeque, - configured_timeouts: Vec, } impl FakeWriter { @@ -418,7 +416,6 @@ mod opening_write_tests { Self { timeout_error: None, actions: actions.into_iter().collect(), - configured_timeouts: Vec::new(), } } } @@ -432,7 +429,10 @@ mod opening_write_tests { } fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { - let action = self.actions.pop_front().unwrap_or(WriteAction::Count(bytes.len())); + let action = self + .actions + .pop_front() + .unwrap_or(WriteAction::Count(bytes.len())); match action { WriteAction::Count(count) => Ok(count.min(bytes.len())), WriteAction::Error(kind) => Err(io::Error::from(kind)), @@ -449,12 +449,10 @@ mod opening_write_tests { ]); let start = Instant::now(); let mut times = VecDeque::from([start, start, start, start]); - let result = write_request_with_clock( - &mut writer, - b"hello", - Duration::from_secs(1), - || times.pop_front().unwrap_or(start), - ); + let result = + write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), || { + times.pop_front().unwrap_or(start) + }); assert_eq!(result, Ok(5)); } @@ -464,26 +462,20 @@ mod opening_write_tests { let mut deadline_writer = FakeWriter::new([]); let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); - let deadline = write_request_with_clock( - &mut deadline_writer, - b"x", - Duration::from_secs(1), - || deadline_times.pop_front().unwrap_or(start), - ); + let deadline = + write_request_with_clock(&mut deadline_writer, b"x", Duration::from_secs(1), || { + deadline_times.pop_front().unwrap_or(start) + }); assert!(matches!( deadline, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 0 - }) + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } + ) )); let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); - let zero = write_request_with_clock( - &mut zero_writer, - b"x", - Duration::from_secs(1), - || start, - ); + let zero = + write_request_with_clock(&mut zero_writer, b"x", Duration::from_secs(1), || start); assert!(matches!( zero, Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) @@ -491,12 +483,8 @@ mod opening_write_tests { for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { let mut writer = FakeWriter::new([WriteAction::Error(kind)]); - let timed_out = write_request_with_clock( - &mut writer, - b"x", - Duration::from_secs(1), - || start, - ); + let timed_out = + write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), || start); assert!(matches!( timed_out, Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { @@ -507,12 +495,8 @@ mod opening_write_tests { } let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - let failed = write_request_with_clock( - &mut failed_writer, - b"x", - Duration::from_secs(1), - || start, - ); + let failed = + write_request_with_clock(&mut failed_writer, b"x", Duration::from_secs(1), || start); assert!(matches!( failed, Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { @@ -531,10 +515,12 @@ mod opening_write_tests { ); assert!(matches!( configuration, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - .. - }) + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + .. + } + ) )); } @@ -544,14 +530,12 @@ mod opening_write_tests { write_timeout: Duration::ZERO, maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, }; - let deadline = WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + let deadline = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; + let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), }; - let configure = - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }; let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { bytes_written: 1, source: io::Error::from(io::ErrorKind::TimedOut), From 89198f28dbc88d42bf9a93739582dda2b91852e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:18:37 -0700 Subject: [PATCH 122/570] style(network): format WebSocket opening exports --- crates/originweave-network/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 289dc4681..fc72c341d 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -25,7 +25,7 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; pub use webdriver_bidi_websocket_handshake::{ - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakeError, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketOpeningRequestSent, - WebDriverBiDiWebSocketOpeningWriteError, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketOpeningRequestSent, WebDriverBiDiWebSocketOpeningWriteError, }; From 6f24ec2d3e90732894c875a288f53bf0fbd787f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:19:16 -0700 Subject: [PATCH 123/570] test(network): verify opening-write evidence redaction --- .../webdriver_bidi_websocket_opening_write.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs index 994cd6980..433774dd4 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs @@ -7,9 +7,9 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketOpeningWriteError, - MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketOpeningWriteError, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -101,9 +101,18 @@ fn bounded_opening_write_sends_exact_request_and_preserves_transport_evidence() assert_eq!(written.request_byte_count(), expected.len()); assert_eq!(written.write_timeout(), write_timeout); assert_eq!(written.client_key().as_str(), RFC6455_SAMPLE_KEY); - assert_eq!(written.transport_evidence().verified_peer().socket_addr(), local_addr); - assert_eq!(written.transport_evidence().verified_peer().session_id(), SESSION_ID); + assert_eq!( + written.transport_evidence().verified_peer().socket_addr(), + local_addr + ); + assert_eq!( + written.transport_evidence().verified_peer().session_id(), + SESSION_ID + ); assert_eq!(written.transport_evidence().attempt_number(), 1); + let debug = format!("{written:?}"); + assert!(debug.contains("WebDriverBiDiWebSocketOpeningRequestSent")); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); let server_result = server.join(); assert!(server_result.is_ok(), "{server_result:?}"); From 25a95006fe2bc1e547bf8e663a374cdf589d7037 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:29:32 -0700 Subject: [PATCH 124/570] test(network): enforce post-write opening deadline --- .../src/webdriver_bidi_websocket_handshake.rs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index aa4d5cc96..9cdd4260b 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -397,7 +397,7 @@ where #[cfg(test)] mod opening_write_tests { use super::*; - use std::{collections::VecDeque, error::Error as _}; + use std::collections::VecDeque; #[derive(Debug)] enum WriteAction { @@ -453,7 +453,30 @@ mod opening_write_tests { write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), || { times.pop_front().unwrap_or(start) }); - assert_eq!(result, Ok(5)); + assert!(matches!(result, Ok(5))); + } + + #[test] + fn bounded_writer_rejects_completion_observed_after_total_deadline() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + let start = Instant::now(); + let mut times = VecDeque::from([ + start, + start, + start + Duration::from_secs(1), + ]); + let result = + write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), || { + times + .pop_front() + .unwrap_or(start + Duration::from_secs(1)) + }); + assert!(matches!( + result, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 1 + }) + )); } #[test] From 2ba07695ac68dd37204321f48db52ea8a533fb63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:35:07 -0700 Subject: [PATCH 125/570] style(network): format opening deadline regression --- .../src/webdriver_bidi_websocket_handshake.rs | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 9cdd4260b..54fdce46d 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -460,22 +460,15 @@ mod opening_write_tests { fn bounded_writer_rejects_completion_observed_after_total_deadline() { let mut writer = FakeWriter::new([WriteAction::Count(1)]); let start = Instant::now(); - let mut times = VecDeque::from([ - start, - start, - start + Duration::from_secs(1), - ]); - let result = - write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), || { - times - .pop_front() - .unwrap_or(start + Duration::from_secs(1)) - }); + let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), || { + times.pop_front().unwrap_or(start + Duration::from_secs(1)) + }); assert!(matches!( result, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 1 - }) + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 } + ) )); } From b94bf131046dd2ae0964744a6d23ee5c1a59a3c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:38:59 -0700 Subject: [PATCH 126/570] fix(network): enforce opening write total deadline --- .../src/webdriver_bidi_websocket_handshake.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 54fdce46d..e5c00fadd 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -369,7 +369,16 @@ where Ok(0) => { return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); } - Ok(count) => bytes_written += count, + Ok(count) => { + bytes_written += count; + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written, + }, + ); + } + } Err(source) if source.kind() == io::ErrorKind::Interrupted => {} Err(source) if matches!( From 96c3704fa4449c43b754c7663c2d31228220acdd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 10:10:13 -0700 Subject: [PATCH 127/570] test(network): cover revoked BiDi opening write --- .../webdriver_bidi_websocket_handshake.rs | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index 264f5a46c..8717d5a82 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -1,9 +1,14 @@ -use std::{net::TcpListener, thread, time::Duration}; +use std::{ + net::{Shutdown, TcpListener}, + thread, + time::Duration, +}; use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketOpeningWriteError, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -81,6 +86,52 @@ fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { } } +#[test] +fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let shutdown = connection.stream().shutdown(Shutdown::Both); + assert!(shutdown.is_ok(), "{shutdown:?}"); + + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let write = plan.write_opening_request(Duration::from_secs(1)); + assert!(matches!( + write, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + .. + }) + )); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + #[test] fn handshake_errors_render_actionable_fail_closed_messages() { assert_eq!( From c4cd29e62c181c1f17b62d73810a56a2421c6d74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 13:16:03 -0700 Subject: [PATCH 128/570] fix(network): unify opening-write coverage control flow --- .../src/webdriver_bidi_websocket_handshake.rs | 77 ++++++++++++------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index e5c00fadd..cffff2a92 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -172,8 +172,9 @@ impl WebDriverBiDiWebSocketHandshakePlan { request, } = self; let (mut stream, transport_evidence) = connection.into_parts(); + let mut now = Instant::now; let request_byte_count = - write_request_with_clock(&mut stream, &request, write_timeout, Instant::now)?; + write_request_with_clock(&mut stream, &request, write_timeout, &mut now)?; Ok(WebDriverBiDiWebSocketOpeningRequestSent { stream, @@ -338,16 +339,12 @@ impl OpeningRequestWriter for TcpStream { } } -fn write_request_with_clock( - writer: &mut W, +fn write_request_with_clock( + writer: &mut dyn OpeningRequestWriter, request: &[u8], write_timeout: Duration, - mut now: N, -) -> Result -where - W: OpeningRequestWriter, - N: FnMut() -> Instant, -{ + now: &mut dyn FnMut() -> Instant, +) -> Result { let deadline = now() + write_timeout; let mut bytes_written = 0; @@ -458,10 +455,13 @@ mod opening_write_tests { ]); let start = Instant::now(); let mut times = VecDeque::from([start, start, start, start]); - let result = - write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), || { - times.pop_front().unwrap_or(start) - }); + let mut now = || times.pop_front().unwrap_or(start); + let result = write_request_with_clock( + &mut writer, + b"hello", + Duration::from_secs(1), + &mut now, + ); assert!(matches!(result, Ok(5))); } @@ -470,9 +470,13 @@ mod opening_write_tests { let mut writer = FakeWriter::new([WriteAction::Count(1)]); let start = Instant::now(); let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); - let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), || { - times.pop_front().unwrap_or(start + Duration::from_secs(1)) - }); + let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); + let result = write_request_with_clock( + &mut writer, + b"x", + Duration::from_secs(1), + &mut now, + ); assert!(matches!( result, Err( @@ -487,10 +491,13 @@ mod opening_write_tests { let mut deadline_writer = FakeWriter::new([]); let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); - let deadline = - write_request_with_clock(&mut deadline_writer, b"x", Duration::from_secs(1), || { - deadline_times.pop_front().unwrap_or(start) - }); + let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); + let deadline = write_request_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + &mut deadline_now, + ); assert!(matches!( deadline, Err( @@ -499,8 +506,13 @@ mod opening_write_tests { )); let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); - let zero = - write_request_with_clock(&mut zero_writer, b"x", Duration::from_secs(1), || start); + let mut zero_now = || start; + let zero = write_request_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + &mut zero_now, + ); assert!(matches!( zero, Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) @@ -508,8 +520,13 @@ mod opening_write_tests { for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { let mut writer = FakeWriter::new([WriteAction::Error(kind)]); - let timed_out = - write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), || start); + let mut now = || start; + let timed_out = write_request_with_clock( + &mut writer, + b"x", + Duration::from_secs(1), + &mut now, + ); assert!(matches!( timed_out, Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { @@ -520,8 +537,13 @@ mod opening_write_tests { } let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - let failed = - write_request_with_clock(&mut failed_writer, b"x", Duration::from_secs(1), || start); + let mut failed_now = || start; + let failed = write_request_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + &mut failed_now, + ); assert!(matches!( failed, Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { @@ -532,11 +554,12 @@ mod opening_write_tests { let mut configuration_writer = FakeWriter::new([]); configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let mut configuration_now = || start; let configuration = write_request_with_clock( &mut configuration_writer, b"x", Duration::from_secs(1), - || start, + &mut configuration_now, ); assert!(matches!( configuration, From 35815f3c6c7b100f6af855431f27a1f8042bcf44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 13:19:40 -0700 Subject: [PATCH 129/570] style(network): apply canonical rustfmt --- .../src/webdriver_bidi_websocket_handshake.rs | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index cffff2a92..ca96679a4 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -456,12 +456,8 @@ mod opening_write_tests { let start = Instant::now(); let mut times = VecDeque::from([start, start, start, start]); let mut now = || times.pop_front().unwrap_or(start); - let result = write_request_with_clock( - &mut writer, - b"hello", - Duration::from_secs(1), - &mut now, - ); + let result = + write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); assert!(matches!(result, Ok(5))); } @@ -471,12 +467,7 @@ mod opening_write_tests { let start = Instant::now(); let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); - let result = write_request_with_clock( - &mut writer, - b"x", - Duration::from_secs(1), - &mut now, - ); + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); assert!(matches!( result, Err( @@ -521,12 +512,8 @@ mod opening_write_tests { for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { let mut writer = FakeWriter::new([WriteAction::Error(kind)]); let mut now = || start; - let timed_out = write_request_with_clock( - &mut writer, - b"x", - Duration::from_secs(1), - &mut now, - ); + let timed_out = + write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); assert!(matches!( timed_out, Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { From 193f1159eab42ce61d62e51f267cb9198597d95f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:11:49 -0700 Subject: [PATCH 130/570] test(network): exercise BiDi opening assertion branches --- .../src/webdriver_bidi_websocket_handshake.rs | 126 ++++++++++++------ 1 file changed, 87 insertions(+), 39 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index ca96679a4..e10435315 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -458,7 +458,11 @@ mod opening_write_tests { let mut now = || times.pop_front().unwrap_or(start); let result = write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); - assert!(matches!(result, Ok(5))); + let is_five = |candidate: Result| { + matches!(candidate, Ok(5)) + }; + assert!(is_five(result)); + assert!(!is_five(Ok(4))); } #[test] @@ -468,12 +472,19 @@ mod opening_write_tests { let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - assert!(matches!( - result, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 } - ) - )); + let is_deadline_after_one = + |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 1 + }) + ) + }; + assert!(is_deadline_after_one(result)); + assert!(!is_deadline_after_one(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); } #[test] @@ -489,12 +500,19 @@ mod opening_write_tests { Duration::from_secs(1), &mut deadline_now, ); - assert!(matches!( - deadline, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } - ) - )); + let is_deadline_before_write = + |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 0 + }) + ) + }; + assert!(is_deadline_before_write(deadline)); + assert!(!is_deadline_before_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); let mut zero_now = || start; @@ -504,23 +522,40 @@ mod opening_write_tests { Duration::from_secs(1), &mut zero_now, ); - assert!(matches!( - zero, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) - )); + let is_zero_write = + |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) + ) + }; + assert!(is_zero_write(zero)); + assert!(!is_zero_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } + ))); for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { let mut writer = FakeWriter::new([WriteAction::Error(kind)]); let mut now = || start; let timed_out = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - assert!(matches!( - timed_out, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + let is_timed_out = + |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_timed_out(timed_out)); + assert!(!is_timed_out(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { bytes_written: 0, - .. - }) - )); + source: io::Error::from(kind), + } + ))); } let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); @@ -531,13 +566,19 @@ mod opening_write_tests { Duration::from_secs(1), &mut failed_now, ); - assert!(matches!( - failed, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - .. - }) - )); + let is_failed = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_failed(failed)); + assert!(!is_failed(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); let mut configuration_writer = FakeWriter::new([]); configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); @@ -548,15 +589,22 @@ mod opening_write_tests { Duration::from_secs(1), &mut configuration_now, ); - assert!(matches!( - configuration, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - .. - } - ) - )); + let is_configuration_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + .. + } + ) + ) + }; + assert!(is_configuration_failure(configuration)); + assert!(!is_configuration_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); } #[test] From fe232ebd54c2e0b4bf3c0dddd13bddbec1d7fcd1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 21:17:47 -0700 Subject: [PATCH 131/570] style(network): apply canonical rustfmt to BiDi opening tests --- .../src/webdriver_bidi_websocket_handshake.rs | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index e10435315..0229f2bf3 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -476,9 +476,11 @@ mod opening_write_tests { |candidate: Result| { matches!( candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 1 - }) + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 1 + } + ) ) }; assert!(is_deadline_after_one(result)); @@ -504,9 +506,11 @@ mod opening_write_tests { |candidate: Result| { matches!( candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 0 - }) + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 0 + } + ) ) }; assert!(is_deadline_before_write(deadline)); @@ -522,13 +526,12 @@ mod opening_write_tests { Duration::from_secs(1), &mut zero_now, ); - let is_zero_write = - |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) - ) - }; + let is_zero_write = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) + ) + }; assert!(is_zero_write(zero)); assert!(!is_zero_write(Err( WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } From efb260b5da083855565f2104431b641074a58278 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 22:07:34 -0700 Subject: [PATCH 132/570] test(sensitive): reject revocation at exclusive expiry --- .../tests/sensitive_handle_lifecycle_evidence.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs index 0aef58535..defc6f8de 100644 --- a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs +++ b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs @@ -111,7 +111,7 @@ fn rejects_zero_use_limit_or_resolution_count_above_limit() { #[test] fn rejects_revocation_outside_handle_lifetime() { - for revoked in [1_719_999_999, 1_720_000_301] { + for revoked in [1_719_999_999, 1_720_000_300, 1_720_000_301] { let mut input = valid_input(); input.revoked_epoch_seconds = Some(revoked); assert_eq!( From 80ea6c418ab8da1b3b3106039f48de9d5fc4d0d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 22:11:07 -0700 Subject: [PATCH 133/570] fix(sensitive): keep revocation inside exclusive lifetime --- crates/originweave-evidence/src/sensitive_handle_lifecycle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs index 512406015..de6e49b1e 100644 --- a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs +++ b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs @@ -56,7 +56,7 @@ impl TryFrom for SensitiveHandleLifecycle || input.maximum_uses == 0 || input.resolution_count > input.maximum_uses || input.revoked_epoch_seconds.is_some_and(|revoked| { - revoked < input.issued_epoch_seconds || revoked > input.expires_epoch_seconds + revoked < input.issued_epoch_seconds || revoked >= input.expires_epoch_seconds }) { return Err(SensitiveEvidenceError::InvalidLifecycle); From 38a185b59b6e75359c814e209d7373ae3d7bb7f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 22:13:29 -0700 Subject: [PATCH 134/570] docs(sensitive): record lifecycle evidence contract --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d17419927..bac3bcb59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Authenticated `originweave-tls` service-identity boundary that consumes an existing verified TCP stream, requires exact TLS-origin and transport-origin equality, derives RFC 9525 DNS or literal-IP reference identity only from the canonical HTTPS origin, validates WebPKI with explicit roots and fixed time, permits only TLS 1.2 and TLS 1.3, and never reconnects or resolves. - Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior and `NotConfigured` revocation evidence. - Credential-free TLS evidence containing canonical origin, requested and observed peer, DNS/IP reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration. +- Credential-free sensitive-handle lifecycle evidence records issuance, exclusive expiry, bounded uses, observed resolution count, and revocation without storing opaque handle tokens or protected values. - Credential-free connection and redirect evidence containing canonical addresses, destination classes, target digests, hop numbers, and approved-address counts. - Credential-free verified TCP evidence containing the logical origin, requested socket, observed peer, destination class, successful attempt number, and per-attempt timeout. - Standard `Display` and `std::error::Error` contracts for destination, redirect, digest, direct-network, and TLS failures, including preserved destination-policy, rustls, and operating-system sources where applicable. From 8ba05a11442ecd102cea87cad79609f0b8b77a87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 23:07:13 -0700 Subject: [PATCH 135/570] test(network): require opening-write timeout cleanup --- ..._opening_write_timeout_cleanup_contract.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/test_webdriver_bidi_opening_write_timeout_cleanup_contract.py diff --git a/tests/test_webdriver_bidi_opening_write_timeout_cleanup_contract.py b/tests/test_webdriver_bidi_opening_write_timeout_cleanup_contract.py new file mode 100644 index 000000000..2847b278d --- /dev/null +++ b/tests/test_webdriver_bidi_opening_write_timeout_cleanup_contract.py @@ -0,0 +1,26 @@ +from pathlib import Path +import unittest + + +SOURCE = Path("crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs") + + +class WebDriverBiDiOpeningWriteTimeoutCleanupContract(unittest.TestCase): + def test_successful_opening_write_clears_operation_local_socket_timeout(self) -> None: + source = SOURCE.read_text(encoding="utf-8") + + self.assertIn("fn clear_write_timeout(&self) -> io::Result<()>;", source) + self.assertIn("TcpStream::set_write_timeout(self, None)", source) + + helper_start = source.index("fn write_request_with_clock(") + helper_end = source.index("\n#[cfg(test)]", helper_start) + helper = source[helper_start:helper_end] + clear_call = helper.rfind("writer.clear_write_timeout()") + success = helper.rfind("Ok(bytes_written)") + + self.assertGreater(clear_call, -1) + self.assertGreater(success, clear_call) + + +if __name__ == "__main__": + unittest.main() From 26a98ade4079df9de642376b9ee6c194390cded3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 23:09:19 -0700 Subject: [PATCH 136/570] fix(network): clear opening-write timeout before handoff --- .../src/webdriver_bidi_websocket_handshake.rs | 100 ++++++++++++++++-- 1 file changed, 90 insertions(+), 10 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 0229f2bf3..b31ef592f 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -148,10 +148,12 @@ impl WebDriverBiDiWebSocketHandshakePlan { /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the /// destination, or retries after any other I/O failure. A partial write that cannot finish before - /// the same monotonic deadline is an error and yields no successful handoff. Success preserves - /// the live stream, exact transport evidence, and client key for a separately reviewed server - /// handshake validator. It does not read or validate the server response and therefore does not - /// establish WebSocket protocol state or browser/Agent authority. + /// the same monotonic deadline is an error and yields no successful handoff. Before success, the + /// operation-local socket write timeout is cleared so the next separately reviewed protocol stage + /// cannot inherit stale timeout authority. Success preserves the live stream, exact transport + /// evidence, and client key for a separately reviewed server handshake validator. It does not + /// read or validate the server response and therefore does not establish WebSocket protocol state + /// or browser/Agent authority. pub fn write_opening_request( self, write_timeout: Duration, @@ -189,10 +191,11 @@ impl WebDriverBiDiWebSocketHandshakePlan { /// A live verified stream after the complete client opening request has been written. /// /// This state proves only that the exact bounded RFC 6455 client request reached the operating -/// system's verified TCP stream before the configured deadline. It deliberately does not claim that -/// the peer returned `101 Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket -/// is established, or that the peer is the expected Chromium/ChromeDriver process. Those remain -/// separate fail-closed boundaries. +/// system's verified TCP stream before the configured deadline and that this operation's socket write +/// timeout was cleared before handoff. It deliberately does not claim that the peer returned `101 +/// Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket is established, or +/// that the peer is the expected Chromium/ChromeDriver process. Those remain separate fail-closed +/// boundaries. pub struct WebDriverBiDiWebSocketOpeningRequestSent { pub(crate) stream: TcpStream, transport_evidence: WebDriverBiDiTcpConnectionEvidence, @@ -284,6 +287,13 @@ pub enum WebDriverBiDiWebSocketOpeningWriteError { /// Underlying operating-system error. source: io::Error, }, + /// Clearing the operation-local socket write timeout failed after all request bytes were sent. + WriteTimeoutCleanupFailed { + /// Number of request bytes already written before cleanup failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, } impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { @@ -307,6 +317,9 @@ impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { Self::WriteFailed { .. } => formatter.write_str( "WebDriver BiDi WebSocket opening write failed before the request was complete", ), + Self::WriteTimeoutCleanupFailed { .. } => formatter.write_str( + "failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff", + ), } } } @@ -316,7 +329,8 @@ impl Error for WebDriverBiDiWebSocketOpeningWriteError { match self { Self::WriteTimeoutConfigurationFailed { source, .. } | Self::WriteTimedOut { source, .. } - | Self::WriteFailed { source, .. } => Some(source), + | Self::WriteFailed { source, .. } + | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), Self::InvalidWriteTimeout { .. } | Self::WriteDeadlineExceeded { .. } | Self::WriteZero { .. } => None, @@ -326,6 +340,7 @@ impl Error for WebDriverBiDiWebSocketOpeningWriteError { trait OpeningRequestWriter { fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; + fn clear_write_timeout(&self) -> io::Result<()>; fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; } @@ -334,6 +349,10 @@ impl OpeningRequestWriter for TcpStream { TcpStream::set_write_timeout(self, Some(timeout)) } + fn clear_write_timeout(&self) -> io::Result<()> { + TcpStream::set_write_timeout(self, None) + } + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { self.write(bytes) } @@ -397,13 +416,20 @@ fn write_request_with_clock( } } + writer.clear_write_timeout().map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written, + source, + } + })?; + Ok(bytes_written) } #[cfg(test)] mod opening_write_tests { use super::*; - use std::collections::VecDeque; + use std::{collections::VecDeque, net::TcpListener, thread}; #[derive(Debug)] enum WriteAction { @@ -414,6 +440,7 @@ mod opening_write_tests { #[derive(Debug)] struct FakeWriter { timeout_error: Option, + clear_timeout_error: Option, actions: VecDeque, } @@ -421,6 +448,7 @@ mod opening_write_tests { fn new(actions: impl IntoIterator) -> Self { Self { timeout_error: None, + clear_timeout_error: None, actions: actions.into_iter().collect(), } } @@ -434,6 +462,13 @@ mod opening_write_tests { Ok(()) } + fn clear_write_timeout(&self) -> io::Result<()> { + if let Some(kind) = self.clear_timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { let action = self .actions @@ -465,6 +500,45 @@ mod opening_write_tests { assert!(!is_five(Ok(4))); } + #[test] + fn bounded_writer_clears_real_socket_timeout_before_success() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback listener"); + let address = listener.local_addr().expect("read loopback address"); + let server = thread::spawn(move || listener.accept().map(|_| ())); + let mut stream = TcpStream::connect(address).expect("connect loopback stream"); + let start = Instant::now(); + let mut now = || start; + + let result = write_request_with_clock( + &mut stream, + b"opening", + Duration::from_secs(1), + &mut now, + ); + + assert!(matches!(result, Ok(7))); + assert_eq!(stream.write_timeout().expect("inspect write timeout"), None); + assert!(server.join().expect("join loopback server").is_ok()); + } + + #[test] + fn bounded_writer_rejects_cleanup_failure_without_success_handoff() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); + let start = Instant::now(); + let mut now = || start; + + let result = + write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + assert!(matches!( + result, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + .. + }) + )); + } + #[test] fn bounded_writer_rejects_completion_observed_after_total_deadline() { let mut writer = FakeWriter::new([WriteAction::Count(1)]); @@ -631,6 +705,10 @@ mod opening_write_tests { bytes_written: 1, source: io::Error::from(io::ErrorKind::BrokenPipe), }; + let cleanup = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; assert!(!invalid.to_string().is_empty()); assert!(!deadline.to_string().is_empty()); @@ -638,11 +716,13 @@ mod opening_write_tests { assert!(!timed_out.to_string().is_empty()); assert!(!zero.to_string().is_empty()); assert!(!failed.to_string().is_empty()); + assert!(!cleanup.to_string().is_empty()); assert!(invalid.source().is_none()); assert!(deadline.source().is_none()); assert!(configure.source().is_some()); assert!(timed_out.source().is_some()); assert!(zero.source().is_none()); assert!(failed.source().is_some()); + assert!(cleanup.source().is_some()); } } From 732cee8f537400f4ffeaeb152198c4a7e9d57455 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 23:11:46 -0700 Subject: [PATCH 137/570] style(network): apply canonical rustfmt --- .../src/webdriver_bidi_websocket_handshake.rs | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index b31ef592f..cadf4f981 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -509,12 +509,8 @@ mod opening_write_tests { let start = Instant::now(); let mut now = || start; - let result = write_request_with_clock( - &mut stream, - b"opening", - Duration::from_secs(1), - &mut now, - ); + let result = + write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now); assert!(matches!(result, Ok(7))); assert_eq!(stream.write_timeout().expect("inspect write timeout"), None); @@ -528,14 +524,15 @@ mod opening_write_tests { let start = Instant::now(); let mut now = || start; - let result = - write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); assert!(matches!( result, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - .. - }) + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + .. + } + ) )); } From a9555f888d0b3fd88cc884de363a732724ae8c02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 23:15:55 -0700 Subject: [PATCH 138/570] test(network): keep timeout cleanup regression clippy-clean --- .../src/webdriver_bidi_websocket_handshake.rs | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index cadf4f981..afe43db73 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -502,10 +502,19 @@ mod opening_write_tests { #[test] fn bounded_writer_clears_real_socket_timeout_before_success() { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback listener"); - let address = listener.local_addr().expect("read loopback address"); + let listener = match TcpListener::bind(("127.0.0.1", 0)) { + Ok(listener) => listener, + Err(error) => panic!("failed to bind loopback listener: {error}"), + }; + let address = match listener.local_addr() { + Ok(address) => address, + Err(error) => panic!("failed to read loopback listener address: {error}"), + }; let server = thread::spawn(move || listener.accept().map(|_| ())); - let mut stream = TcpStream::connect(address).expect("connect loopback stream"); + let mut stream = match TcpStream::connect(address) { + Ok(stream) => stream, + Err(error) => panic!("failed to connect loopback stream: {error}"), + }; let start = Instant::now(); let mut now = || start; @@ -513,8 +522,16 @@ mod opening_write_tests { write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now); assert!(matches!(result, Ok(7))); - assert_eq!(stream.write_timeout().expect("inspect write timeout"), None); - assert!(server.join().expect("join loopback server").is_ok()); + let write_timeout = match stream.write_timeout() { + Ok(write_timeout) => write_timeout, + Err(error) => panic!("failed to inspect write timeout: {error}"), + }; + assert_eq!(write_timeout, None); + let server_result = match server.join() { + Ok(server_result) => server_result, + Err(_) => panic!("loopback server thread panicked"), + }; + assert!(server_result.is_ok()); } #[test] From 865deb61a1f288c77bdf4f514b054a8a990087d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 23:50:27 -0700 Subject: [PATCH 139/570] test(network): keep socket-timeout cleanup regression lint-clean --- .../src/webdriver_bidi_websocket_handshake.rs | 30 +++++-------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index afe43db73..4fa4ee9a5 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -501,20 +501,11 @@ mod opening_write_tests { } #[test] - fn bounded_writer_clears_real_socket_timeout_before_success() { - let listener = match TcpListener::bind(("127.0.0.1", 0)) { - Ok(listener) => listener, - Err(error) => panic!("failed to bind loopback listener: {error}"), - }; - let address = match listener.local_addr() { - Ok(address) => address, - Err(error) => panic!("failed to read loopback listener address: {error}"), - }; + fn bounded_writer_clears_real_socket_timeout_before_success() -> io::Result<()> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let address = listener.local_addr()?; let server = thread::spawn(move || listener.accept().map(|_| ())); - let mut stream = match TcpStream::connect(address) { - Ok(stream) => stream, - Err(error) => panic!("failed to connect loopback stream: {error}"), - }; + let mut stream = TcpStream::connect(address)?; let start = Instant::now(); let mut now = || start; @@ -522,16 +513,9 @@ mod opening_write_tests { write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now); assert!(matches!(result, Ok(7))); - let write_timeout = match stream.write_timeout() { - Ok(write_timeout) => write_timeout, - Err(error) => panic!("failed to inspect write timeout: {error}"), - }; - assert_eq!(write_timeout, None); - let server_result = match server.join() { - Ok(server_result) => server_result, - Err(_) => panic!("loopback server thread panicked"), - }; - assert!(server_result.is_ok()); + assert_eq!(stream.write_timeout()?, None); + assert!(matches!(server.join(), Ok(Ok(())))); + Ok(()) } #[test] From 4e08b8c3da23fe99df0d2a9f9512f24633247381 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 03:11:25 -0700 Subject: [PATCH 140/570] test(network): close exact coverage gaps in BiDi opening write --- .../src/webdriver_bidi_websocket_handshake.rs | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 4fa4ee9a5..1caefed02 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -501,21 +501,33 @@ mod opening_write_tests { } #[test] - fn bounded_writer_clears_real_socket_timeout_before_success() -> io::Result<()> { - let listener = TcpListener::bind(("127.0.0.1", 0))?; - let address = listener.local_addr()?; + fn bounded_writer_clears_real_socket_timeout_before_success() { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .expect("loopback listener must bind for the real socket timeout regression"); + let address = listener + .local_addr() + .expect("bound loopback listener must expose its local address"); let server = thread::spawn(move || listener.accept().map(|_| ())); - let mut stream = TcpStream::connect(address)?; + let mut stream = TcpStream::connect(address) + .expect("client must connect to the already-bound loopback listener"); let start = Instant::now(); let mut now = || start; - let result = - write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now); - - assert!(matches!(result, Ok(7))); - assert_eq!(stream.write_timeout()?, None); - assert!(matches!(server.join(), Ok(Ok(())))); - Ok(()) + let request_byte_count = + write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) + .expect("bounded opening write must complete on the connected loopback stream"); + + assert_eq!(request_byte_count, 7); + assert_eq!( + stream + .write_timeout() + .expect("live stream must expose its cleared write timeout"), + None + ); + server + .join() + .expect("loopback server thread must not panic") + .expect("loopback server must accept the client connection"); } #[test] @@ -526,15 +538,22 @@ mod opening_write_tests { let mut now = || start; let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - assert!(matches!( - result, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - .. - } - ) - )); + let is_cleanup_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + .. + } + ) + ) + }; + assert!(is_cleanup_failure(result)); + assert!(!is_cleanup_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); } #[test] From ed45c9988ae343ebd4c3d22424a1718bd0279f7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 19:48:00 +0900 Subject: [PATCH 141/570] fix(network): keep opening write test clippy clean --- CHANGELOG.md | 1 + .../src/webdriver_bidi_websocket_handshake.rs | 31 +++++++------------ 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5598829e7..8b9bacaed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Separated hourly product PR publication authority from the organization review and merge system, and added live default-branch and release-blocker rechecks immediately before publication. - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. +- Made the real loopback WebDriver BiDi opening-write regression test propagate setup, I/O, and thread-join errors so the strict all-target Clippy gate remains clean without weakening the test. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. ### Security diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 1caefed02..1d4f3e872 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -501,33 +501,24 @@ mod opening_write_tests { } #[test] - fn bounded_writer_clears_real_socket_timeout_before_success() { - let listener = TcpListener::bind(("127.0.0.1", 0)) - .expect("loopback listener must bind for the real socket timeout regression"); - let address = listener - .local_addr() - .expect("bound loopback listener must expose its local address"); + fn bounded_writer_clears_real_socket_timeout_before_success() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let address = listener.local_addr()?; let server = thread::spawn(move || listener.accept().map(|_| ())); - let mut stream = TcpStream::connect(address) - .expect("client must connect to the already-bound loopback listener"); + let mut stream = TcpStream::connect(address)?; let start = Instant::now(); let mut now = || start; let request_byte_count = - write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) - .expect("bounded opening write must complete on the connected loopback stream"); + write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now)?; assert_eq!(request_byte_count, 7); - assert_eq!( - stream - .write_timeout() - .expect("live stream must expose its cleared write timeout"), - None - ); - server - .join() - .expect("loopback server thread must not panic") - .expect("loopback server must accept the client connection"); + assert_eq!(stream.write_timeout()?, None); + match server.join() { + Ok(result) => result?, + Err(_) => return Err("loopback server thread panicked".into()), + } + Ok(()) } #[test] From 29a310cf1ab67ca514d3215449eb050656eb2d0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:05:51 +0900 Subject: [PATCH 142/570] test(network): cover loopback server panic cleanup --- CHANGELOG.md | 1 + .../src/webdriver_bidi_websocket_handshake.rs | 25 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b9bacaed..aed2387f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. - Made the real loopback WebDriver BiDi opening-write regression test propagate setup, I/O, and thread-join errors so the strict all-target Clippy gate remains clean without weakening the test. +- Covered the loopback-server panic cleanup branch so exact production coverage proves both successful and failed thread handoff paths. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. ### Security diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 1d4f3e872..33ee8e719 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -500,6 +500,16 @@ mod opening_write_tests { assert!(!is_five(Ok(4))); } + fn join_loopback_server( + server: thread::JoinHandle>, + ) -> Result<(), Box> { + match server.join() { + Ok(result) => result?, + Err(_) => return Err("loopback server thread panicked".into()), + } + Ok(()) + } + #[test] fn bounded_writer_clears_real_socket_timeout_before_success() -> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; @@ -514,10 +524,17 @@ mod opening_write_tests { assert_eq!(request_byte_count, 7); assert_eq!(stream.write_timeout()?, None); - match server.join() { - Ok(result) => result?, - Err(_) => return Err("loopback server thread panicked".into()), - } + join_loopback_server(server) + } + + #[test] + fn panicked_loopback_server_is_reported() -> Result<(), Box> { + let server = thread::spawn(|| -> io::Result<()> { + std::panic::resume_unwind(Box::new("intentional test-only server panic")); + }); + + let result = join_loopback_server(server); + assert!(result.is_err()); Ok(()) } From 5c2c8975d75a890d6f84e0e0d424ca7fee9bebb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:11:57 +0900 Subject: [PATCH 143/570] docs: record product and technical gap baseline --- ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + docs/README.md | 1 + docs/product-technical-gap-baseline.md | 79 ++++++++++++++++++++ tests/test_product_documentation_contract.py | 20 +++++ 5 files changed, 102 insertions(+) create mode 100644 docs/product-technical-gap-baseline.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9b23ef9f0..fe287389b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,6 +12,7 @@ This file is the canonical product-wide topology and bounded-context view. It is - [Requirement, decision, standards, and implementation traceability](docs/traceability/README.md) - [Research and standards doctoring](docs/doctoring.md) - [Product roadmap](docs/product-roadmap.md) +- [Live product and technical gap baseline](docs/product-technical-gap-baseline.md) Protected-main code and executable tests define current implementation truth; deployed build/release artifacts, migrations, and configuration are additional operational evidence when they exist. Accepted ADRs define design authority, not proof that planned behavior has shipped. The PRD/TRD/diagrams may also contain `Planned`, `Proposed`, or `Open` product direction; those labels must remain explicit until corresponding implementation and review evidence reaches protected `main`. diff --git a/CHANGELOG.md b/CHANGELOG.md index d17419927..18de61797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Added a dated product and technical gap baseline that separates protected-main implementation truth, active pull-request evidence, live review/check blockers, and the next buyer-visible Phase 1 acceptance work. - 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. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/docs/README.md b/docs/README.md index 03b573c54..775dd0de6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ - [OriginWeave API and protocol contract](API_CONTRACT.md) - [Release and rollback contract](RELEASE_AND_ROLLBACK.md) - [Product roadmap](product-roadmap.md) +- [Product and technical gap baseline](product-technical-gap-baseline.md) - [Research and standards](doctoring.md) - [Browser and Agent protocol standards evidence](doctoring/browser-agent-protocols.md) - [Current product-baseline standards addendum](doctoring/product-documentation-baseline.md) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 000000000..9f40cb459 --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,79 @@ +# Product and Technical Gap Baseline + +This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, or architecture decisions. It keeps buyer-visible gaps and volatile repository evidence in one discoverable place. Protected `main` is the implementation boundary: code in an open pull request is not shipped behavior. + +## Observed snapshot: 2026-08-20 + +### Protected-main truth + +- Protected `main` and `origin/main` were both at `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` when this snapshot was prepared. +- Phase 0 is documented as complete as a reusable safety-kernel foundation: typed policy contracts, destination classification, direct TCP peer verification, TLS service identity, evidence bounds, resource mitigation, document-node authority, and their protected-main tests. +- Phase 1 is **in progress**, not shipped. The first real Chromium vertical slice still needs an ephemeral Chromium context, a versioned WebDriver BiDi/browser adapter, semantic observation and typed actions, post-condition evidence, crash recovery, and proof that Chromium consumed the governed resolution, route, TCP, TLS, and HTTP boundaries. +- HTTP/1.1 bounds, download/MIME limits, proxy/PAC execution, full browser-network integration, the sensitive-data broker runtime, durable WARC/PROV capture, and the complete Manifest V3 compatibility program remain planned or open as recorded in the PRD, TRD, and roadmap. + +### Open pull requests + +The live repository contained **100 open pull requests: 21 non-draft and 79 draft**. The non-draft set was: + +| Pull request | Base | Delivery state at snapshot | +|---|---|---| +| #194, #175, #173, #168, #166, #164, #163, #161, #160, #159, #158, #157, #156, #152, #124 | `main` | Ready/non-draft inventory; current review and check state must be re-fetched before merge | +| #149 | `main` | WireGuard/IKEv2 profile authority; exact head `b2be2e7`, Rust contracts and Production coverage successful, remaining required workflows were queued | +| #153, #151, #150, #148, #147 | stacked | Non-draft teardown/crash-recovery work; base-branch ordering applies | + +Draft PR #195 is the current WebDriver BiDi opening-write repair. Its exact head is `29a310c`; Rust contracts are successful and Production coverage was in progress after a coverage-branch repair. It remains draft evidence and cannot be treated as shipped behavior. + +The snapshot also retained an older open failure on #90 (`8721787d`): Rust contracts were successful but Production coverage was failing. That PR is not a protected-main implementation claim. The current exact head and check runs must be re-fetched before any action. + +The 79 draft PRs are intentionally excluded from the merge queue. Several open PRs are stacked, so a green check on a child branch cannot be treated as evidence that its change is mergeable onto protected `main`. + +### Review and merge authority + +The active `CWL Central required workflows` ruleset requires one approving review, approval after the last push, resolved review threads, and the configured required workflows. The live collaborator list contained only `seonghobae` with repository administration and push permissions. This is a **reviewer-provisioning gap**: no eligible independent collaborator was available for a counted non-author approval at snapshot time. + +This gap does not authorize self-approval, administrative bypass, stale-head merge, or weakening checks. Exact current-head checks, security gates, documentation, coverage, rustdoc/Clippy, thread resolution, and branch protection remain mandatory. The solo-maintainer governance condition may place an otherwise impossible independent-review rule on hold only through the documented governance path; it does not turn an unverified PR into shipped behavior. + +### Open issues and operational signals + +| Issue | Current gap or signal | +|---|---| +| #28 | First real Chromium agent vertical slice; highest buyer-visible Phase 1 gap | +| #27 | Complete Manifest V3 compatibility and extension-authority isolation matrix | +| #9 | Bounded HTTP/1.1 semantics over the authenticated TLS stream | +| #10 | Purpose-bound operational PII disclosure and trusted broker/storage lifecycle | +| #123 | Fleet incident: disable orphaned TLS, HTTP, and one-shot workflow identities | +| #187 | Manual-authority review of the coverage-diagnostics workflow delta | + +The hourly product-development loop exists as a bounded, review-separated workflow. Its existence is operational infrastructure, not evidence that the browser product or an hourly run has completed the Phase 1 buyer acceptance. + +## Buyer-visible and technical gap matrix + +| Priority | Buyer-visible outcome | Protected-main status | Next acceptance evidence | +|---|---|---|---| +| P0 | A bounded task can observe a real Chromium page, perform a typed action, verify the post-condition, and emit provenance | **Open / Phase 1**; issue #28 | Repeated real Chromium E2E with ephemeral context, BiDi/session translation, observation, typed action, post-condition, evidence, crash cleanup, and exact current protected checks | +| P1 | Navigation uses the approved destination, route, TCP peer, TLS identity, and bounded HTTP/download policy | **Partial foundation**; HTTP and browser consumption remain planned | Real browser-network adapter proves the governed path is consumed end to end, including redirects, bounds, MIME, and failure evidence | +| P1 | Existing Chromium extensions remain compatible while Agent authority stays separate | **Partial evidence / planned completion**; issue #27 | Pinned-Chromium install/update/service-worker/content/storage/DNR/download/native-messaging/enterprise-isolation matrix with repeatability | +| P1 | Enterprise operators can disclose only necessary sensitive fields through a trusted, auditable path | **Policy foundation implemented; runtime open**; issue #10 | Opaque-handle broker, purpose/field/region policy, atomic reservation/revocation, retention/deletion, audit, and redaction tests | +| P2 | A buyer can receive durable replayable capture and provenance | **Foundations only** | Bounded WARC/PROV persistence, retention, integrity, replay, and benchmark evidence | +| P0 | Changes can pass protected review and merge without authority improvisation | **Blocked by reviewer-provisioning gap** | Provision an eligible independent collaborator or record an explicit current governance decision; then re-fetch exact head, reviews, checks, and merge state | + +## Next executable queue + +1. Re-fetch every active PR's exact head, reviews, threads, required checks, and base before selecting a merge candidate; repair a current failure only after reproducing its root cause. +2. Advance issue #28 with the smallest failing real-browser acceptance test, beginning at ephemeral Chromium launch/session teardown and the BiDi adapter boundary. +3. Keep HTTP/1.1 and browser-network integration separate from the already-proven destination, direct TCP, and TLS kernels; do not claim safe navigation until Chromium consumption is observed. +4. Maintain the #27 extension matrix and #10 broker/runtime boundaries as independent acceptance tracks. +5. Resolve the reviewer-provisioning gap through legitimate repository governance before a non-author approval is required; never manufacture approval or bypass protection. + +## Evidence commands + +The volatile values above were obtained from the repository and GitHub APIs, without exposing credentials: + +```text +gh api repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100 +gh api repos/ContextualWisdomLab/OriginWeave/commits//check-runs?per_page=100 +gh api repos/ContextualWisdomLab/OriginWeave/rulesets/18156473 +gh api repos/ContextualWisdomLab/OriginWeave/collaborators?affiliation=all&per_page=100 +``` + +For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and the linked ADR/UML/ERD/traceability graph. This baseline intentionally records delivery state and does not promote planned adapters or open pull-request code to implemented behavior. diff --git a/tests/test_product_documentation_contract.py b/tests/test_product_documentation_contract.py index 1313189ea..fc6dd4f2d 100644 --- a/tests/test_product_documentation_contract.py +++ b/tests/test_product_documentation_contract.py @@ -25,10 +25,29 @@ def test_authoritative_product_documentation_graph_exists(self) -> None: "docs/OPERABILITY.md", "docs/API_CONTRACT.md", "docs/RELEASE_AND_ROLLBACK.md", + "docs/product-technical-gap-baseline.md", } missing = sorted(path for path in required_paths if not (ROOT / path).is_file()) self.assertEqual(missing, []) + def test_product_technical_gap_baseline_records_live_delivery_state(self) -> None: + """Buyers and maintainers must see implementation gaps and current delivery blockers together.""" + baseline = ROOT / "docs/product-technical-gap-baseline.md" + self.assertTrue(baseline.is_file()) + text = baseline.read_text(encoding="utf-8") + for phrase in ( + "Observed snapshot: 2026-08-20", + "Protected-main truth", + "Open pull requests", + "Open issues", + "#195", + "#149", + "reviewer-provisioning gap", + "Phase 1", + ): + with self.subTest(phrase=phrase): + self.assertIn(phrase, text) + def test_root_architecture_links_the_authoritative_product_graph(self) -> None: """Architecture readers must be able to reach requirements, decisions, diagrams, and data.""" architecture = (ROOT / "ARCHITECTURE.md").read_text(encoding="utf-8") @@ -39,6 +58,7 @@ def test_root_architecture_links_the_authoritative_product_graph(self) -> None: "docs/uml/README.md", "docs/erd/README.md", "docs/traceability/README.md", + "docs/product-technical-gap-baseline.md", ): with self.subTest(link=link): self.assertIn(link, architecture) From 4ff33b5c339b25a3bf82dae88b85d7ecee4eb0e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:18:45 +0900 Subject: [PATCH 144/570] test(network): keep opening-write coverage exact --- CHANGELOG.md | 3 +- .../src/webdriver_bidi_websocket_handshake.rs | 41 +++++++++++-------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aed2387f6..8a3ba2526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,8 +62,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Separated hourly product PR publication authority from the organization review and merge system, and added live default-branch and release-blocker rechecks immediately before publication. - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. -- Made the real loopback WebDriver BiDi opening-write regression test propagate setup, I/O, and thread-join errors so the strict all-target Clippy gate remains clean without weakening the test. -- Covered the loopback-server panic cleanup branch so exact production coverage proves both successful and failed thread handoff paths. +- Kept the real loopback WebDriver BiDi opening-write regression test fail-fast with test-only diagnostics, while explicitly covering successful and panicked server-thread handoffs so strict all-target Clippy and exact coverage remain clean. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. ### Security diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 33ee8e719..d8ca9bd98 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -427,6 +427,7 @@ fn write_request_with_clock( } #[cfg(test)] +#[allow(clippy::expect_used)] mod opening_write_tests { use super::*; use std::{collections::VecDeque, net::TcpListener, thread}; @@ -500,42 +501,48 @@ mod opening_write_tests { assert!(!is_five(Ok(4))); } - fn join_loopback_server( - server: thread::JoinHandle>, - ) -> Result<(), Box> { + fn join_loopback_server(server: thread::JoinHandle>) -> bool { match server.join() { - Ok(result) => result?, - Err(_) => return Err("loopback server thread panicked".into()), + Ok(result) => { + result.expect("loopback server must accept the client"); + false + } + Err(_) => true, } - Ok(()) } #[test] - fn bounded_writer_clears_real_socket_timeout_before_success() -> Result<(), Box> { - let listener = TcpListener::bind(("127.0.0.1", 0))?; - let address = listener.local_addr()?; + fn bounded_writer_clears_real_socket_timeout_before_success() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); let server = thread::spawn(move || listener.accept().map(|_| ())); - let mut stream = TcpStream::connect(address)?; + let mut stream = TcpStream::connect(address).expect("test client must connect"); let start = Instant::now(); let mut now = || start; let request_byte_count = - write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now)?; + write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) + .expect("the opening request must be written"); assert_eq!(request_byte_count, 7); - assert_eq!(stream.write_timeout()?, None); - join_loopback_server(server) + assert_eq!( + stream + .write_timeout() + .expect("the socket timeout must be inspectable"), + None + ); + assert!(!join_loopback_server(server)); } #[test] - fn panicked_loopback_server_is_reported() -> Result<(), Box> { + fn panicked_loopback_server_is_reported() { let server = thread::spawn(|| -> io::Result<()> { std::panic::resume_unwind(Box::new("intentional test-only server panic")); }); - let result = join_loopback_server(server); - assert!(result.is_err()); - Ok(()) + assert!(join_loopback_server(server)); } #[test] From de9afd397f786201d54dea9ecae5541083bd5e7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:19:37 +0900 Subject: [PATCH 145/570] docs: refresh live pull request inventory --- docs/product-technical-gap-baseline.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 9f40cb459..1951ae667 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -13,15 +13,16 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, o ### Open pull requests -The live repository contained **100 open pull requests: 21 non-draft and 79 draft**. The non-draft set was: +The live repository contained **100 open pull requests: 22 non-draft and 78 draft**. The non-draft set was: | Pull request | Base | Delivery state at snapshot | |---|---|---| | #194, #175, #173, #168, #166, #164, #163, #161, #160, #159, #158, #157, #156, #152, #124 | `main` | Ready/non-draft inventory; current review and check state must be re-fetched before merge | | #149 | `main` | WireGuard/IKEv2 profile authority; exact head `b2be2e7`, Rust contracts and Production coverage successful, remaining required workflows were queued | +| #196 | `main` | This product/technical gap baseline; checks were queued after publication and merge remains review-gated | | #153, #151, #150, #148, #147 | stacked | Non-draft teardown/crash-recovery work; base-branch ordering applies | -Draft PR #195 is the current WebDriver BiDi opening-write repair. Its exact head is `29a310c`; Rust contracts are successful and Production coverage was in progress after a coverage-branch repair. It remains draft evidence and cannot be treated as shipped behavior. +Draft PR #195 is the current WebDriver BiDi opening-write repair. Its exact head is `4ff33b5`; Rust contracts and Production coverage were re-running after the test-only coverage repair. It remains draft evidence and cannot be treated as shipped behavior. The snapshot also retained an older open failure on #90 (`8721787d`): Rust contracts were successful but Production coverage was failing. That PR is not a protected-main implementation claim. The current exact head and check runs must be re-fetched before any action. From 1a613f2d7fd8b29d265821e69cb0f9d9fc5b71d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:21:19 +0900 Subject: [PATCH 146/570] docs: address baseline review findings --- CHANGELOG.md | 2 +- docs/product-technical-gap-baseline.md | 8 ++++---- tests/test_product_documentation_contract.py | 10 ++++++++++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18de61797..2e950d19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Added a dated product and technical gap baseline that separates protected-main implementation truth, active pull-request evidence, live review/check blockers, and the next buyer-visible Phase 1 acceptance work. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. @@ -33,7 +34,6 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed -- Added a dated product and technical gap baseline that separates protected-main implementation truth, active pull-request evidence, live review/check blockers, and the next buyer-visible Phase 1 acceptance work. - 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. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1951ae667..02bef5205 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -71,10 +71,10 @@ The hourly product-development loop exists as a bounded, review-separated workfl The volatile values above were obtained from the repository and GitHub APIs, without exposing credentials: ```text -gh api repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100 -gh api repos/ContextualWisdomLab/OriginWeave/commits//check-runs?per_page=100 -gh api repos/ContextualWisdomLab/OriginWeave/rulesets/18156473 -gh api repos/ContextualWisdomLab/OriginWeave/collaborators?affiliation=all&per_page=100 +gh api 'repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100' +gh api 'repos/ContextualWisdomLab/OriginWeave/commits/0841d2ab3d8b5e60a03c0a8e818cf438e2716829/check-runs?per_page=100' +gh api 'repos/ContextualWisdomLab/OriginWeave/rulesets/18156473' +gh api 'repos/ContextualWisdomLab/OriginWeave/collaborators?affiliation=all&per_page=100' ``` For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and the linked ADR/UML/ERD/traceability graph. This baseline intentionally records delivery state and does not promote planned adapters or open pull-request code to implemented behavior. diff --git a/tests/test_product_documentation_contract.py b/tests/test_product_documentation_contract.py index fc6dd4f2d..cb4f5a574 100644 --- a/tests/test_product_documentation_contract.py +++ b/tests/test_product_documentation_contract.py @@ -48,6 +48,16 @@ def test_product_technical_gap_baseline_records_live_delivery_state(self) -> Non with self.subTest(phrase=phrase): self.assertIn(phrase, text) + protected_main = text.split("### Open pull requests", 1)[0] + open_pull_requests = text.split("### Open pull requests", 1)[1].split( + "### Review and merge authority", 1 + )[0] + self.assertIn("Phase 1 is **in progress**, not shipped.", protected_main) + self.assertIn( + "It remains draft evidence and cannot be treated as shipped behavior.", + open_pull_requests, + ) + def test_root_architecture_links_the_authoritative_product_graph(self) -> None: """Architecture readers must be able to reach requirements, decisions, diagrams, and data.""" architecture = (ROOT / "ARCHITECTURE.md").read_text(encoding="utf-8") From 5aa9581ef2bc18e02f839ee22b39b67ed7ef3efe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:41:19 +0900 Subject: [PATCH 147/570] fix(ci): align hourly coverage nightly pin --- .github/workflows/hourly-product-development.yml | 4 ++-- CHANGELOG.md | 1 + docs/doctoring/rust-toolchain-freshness.md | 5 +++-- tests/test_rust_toolchain_contract.py | 5 +++++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/hourly-product-development.yml b/.github/workflows/hourly-product-development.yml index 672754c69..396af4a95 100644 --- a/.github/workflows/hourly-product-development.yml +++ b/.github/workflows/hourly-product-development.yml @@ -154,7 +154,7 @@ jobs: run: | set -euo pipefail rustup toolchain install 1.97.1 --profile minimal --component clippy,rustfmt - rustup toolchain install nightly-2026-08-01 --profile minimal --component llvm-tools-preview + rustup toolchain install nightly-2026-08-18 --profile minimal --component llvm-tools-preview cargo +1.97.1 install cargo-llvm-cov --version 0.8.6 --locked archive="${RUNNER_TEMP}/opencode-linux-x64.tar.gz" curl -fsSL -o "$archive" \ @@ -894,7 +894,7 @@ jobs: cargo +1.97.1 test --locked --workspace --all-targets cargo +1.97.1 clippy --locked --workspace --all-targets -- -D warnings RUSTDOCFLAGS='-D warnings' cargo +1.97.1 doc --locked --workspace --no-deps - cargo +nightly-2026-08-01 llvm-cov \ + cargo +nightly-2026-08-18 llvm-cov \ --locked --workspace --all-features --branch --json --summary-only \ --output-path "${RUNNER_TEMP}/coverage.json" python3 scripts/ci/verify_coverage.py "${RUNNER_TEMP}/coverage.json" diff --git a/CHANGELOG.md b/CHANGELOG.md index d17419927..35e15a860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Aligned the hourly product-development branch-coverage toolchain 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. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/docs/doctoring/rust-toolchain-freshness.md b/docs/doctoring/rust-toolchain-freshness.md index de566637e..a00e7fb08 100644 --- a/docs/doctoring/rust-toolchain-freshness.md +++ b/docs/doctoring/rust-toolchain-freshness.md @@ -33,8 +33,9 @@ controls. ## References -GitHub. (2026). *Dependabot supports updates for Rust toolchains*. GitHub -Changelog. https://github.blog/changelog/ +GitHub. (2025, August 19). *Dependabot now supports Rust toolchain updates*. +GitHub Changelog. +https://github.blog/changelog/2025-08-19-dependabot-now-supports-rust-toolchain-updates/ Rust Project Developers. (2026, July 16). *Announcing Rust 1.97.1*. Rust Blog. https://blog.rust-lang.org/2026/07/16/Rust-1.97.1/ diff --git a/tests/test_rust_toolchain_contract.py b/tests/test_rust_toolchain_contract.py index f60867e31..3a635c368 100644 --- a/tests/test_rust_toolchain_contract.py +++ b/tests/test_rust_toolchain_contract.py @@ -10,6 +10,7 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[1] RUST_TOOLCHAIN = REPOSITORY_ROOT / "rust-toolchain.toml" CI_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "ci.yml" +HOURLY_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "hourly-product-development.yml" DEPENDABOT = REPOSITORY_ROOT / ".github" / "dependabot.yml" @@ -34,6 +35,10 @@ def test_branch_coverage_uses_one_current_date_pinned_nightly(self) -> None: self.assertEqual(workflow.count("nightly-2026-08-18"), 3) self.assertNotIn("nightly-2026-08-01", workflow) + hourly_workflow = HOURLY_WORKFLOW.read_text(encoding="utf-8") + self.assertEqual(hourly_workflow.count("nightly-2026-08-18"), 2) + self.assertNotIn("nightly-2026-08-01", hourly_workflow) + if __name__ == "__main__": # pragma: no cover unittest.main() From e95477dd57e0b86c75b7ee037dab63ea111e2ed6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:45:05 +0900 Subject: [PATCH 148/570] fix(ci): make nightly refresh idempotent --- .github/workflows/apply-rust-nightly-refresh.yml | 15 +++++++++++---- CHANGELOG.md | 2 +- tests/test_rust_toolchain_contract.py | 9 +++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/apply-rust-nightly-refresh.yml b/.github/workflows/apply-rust-nightly-refresh.yml index f393f6889..7f3186b39 100644 --- a/.github/workflows/apply-rust-nightly-refresh.yml +++ b/.github/workflows/apply-rust-nightly-refresh.yml @@ -31,12 +31,19 @@ jobs: source = source_path.read_text(encoding='utf-8') old = 'nightly-2026-08-01' new = 'nightly-2026-08-18' - count = source.count(old) - if count != 2: - raise SystemExit(f'expected exactly 2 predecessor selectors, found {count}') + old_count = source.count(old) + new_count = source.count(new) + if old_count == 2 and new_count == 0: + refreshed_source = source.replace(old, new) + elif old_count == 0 and new_count == 2: + refreshed_source = source + else: + raise SystemExit( + f'expected exactly two selectors in one state, found old={old_count}, new={new_count}' + ) output = Path('nightly-refresh-artifact/hourly-product-development.yml') output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(source.replace(old, new), encoding='utf-8') + output.write_text(refreshed_source, encoding='utf-8') refreshed = output.read_text(encoding='utf-8') if old in refreshed or refreshed.count(new) < 2: raise SystemExit('nightly refresh artifact failed its replacement contract') diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e15a860..879c3407e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed -- Aligned the hourly product-development branch-coverage toolchain with the reviewed `nightly-2026-08-18` pin and corrected the official Dependabot Rust-toolchain reference. +- 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. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/tests/test_rust_toolchain_contract.py b/tests/test_rust_toolchain_contract.py index 3a635c368..058add241 100644 --- a/tests/test_rust_toolchain_contract.py +++ b/tests/test_rust_toolchain_contract.py @@ -11,6 +11,7 @@ RUST_TOOLCHAIN = REPOSITORY_ROOT / "rust-toolchain.toml" CI_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "ci.yml" HOURLY_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "hourly-product-development.yml" +REFRESH_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "apply-rust-nightly-refresh.yml" DEPENDABOT = REPOSITORY_ROOT / ".github" / "dependabot.yml" @@ -39,6 +40,14 @@ def test_branch_coverage_uses_one_current_date_pinned_nightly(self) -> None: self.assertEqual(hourly_workflow.count("nightly-2026-08-18"), 2) self.assertNotIn("nightly-2026-08-01", hourly_workflow) + def test_nightly_refresh_accepts_only_old_or_already_refreshed_source(self) -> None: + """The one-shot materializer remains valid after the source is refreshed.""" + workflow = REFRESH_WORKFLOW.read_text(encoding="utf-8") + self.assertIn("old_count = source.count(old)", workflow) + self.assertIn("new_count = source.count(new)", workflow) + self.assertIn("if old_count == 2 and new_count == 0:", workflow) + self.assertIn("elif old_count == 0 and new_count == 2:", workflow) + if __name__ == "__main__": # pragma: no cover unittest.main() From a9da3e4c478f98c647216335cabebd6beb0ef43b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 21:19:57 +0900 Subject: [PATCH 149/570] feat(network): validate WebSocket opening response --- CHANGELOG.md | 1 + Cargo.lock | 13 + crates/originweave-network/Cargo.toml | 2 + crates/originweave-network/src/lib.rs | 9 +- .../src/webdriver_bidi_websocket_handshake.rs | 845 +++++++++++++++++- .../webdriver_bidi_websocket_opening_write.rs | 159 +++- docs/doctoring/browser-agent-protocols.md | 12 +- 7 files changed, 1023 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a3ba2526..74e3f7a56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Bounded RFC 6455 WebDriver BiDi opening-response validation on the exact peer-verified stream: it admits only HTTP/1.1 `101`, case-insensitive `Upgrade`/`Connection` tokens, and the client-key-correlated `Sec-WebSocket-Accept` value within monotonic time and header-size ceilings; it restores blocking mode and still does not implement WebSocket frames or grant browser/Agent authority. - Bounded WebDriver BiDi loopback TCP transport that consumes one exact no-DNS connect target, retries only explicitly recoverable local transport failures within repository timeout and attempt ceilings, exposes the stream only after operating-system peer inspection and exact peer verification, supports a consuming handoff of the original stream with typed credential-free peer/session/TLS and bounded-attempt evidence, preserves typed causal errors, and performs no DNS, proxy/PAC, process authentication, TLS, WebSocket, BiDi message, browser-action, or Agent-authority step. - Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. - Explicit no-DNS WebDriver BiDi loopback connection targets that derive exact IPv4/IPv6 loopback `SocketAddr` metadata from a session-correlated endpoint, reject `localhost` as requiring separately trusted name resolution, preserve the TLS requirement and exact session id, perform no socket I/O, and grant no Agent authority. diff --git a/Cargo.lock b/Cargo.lock index e2ada3c4e..90b2ed7c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,8 +285,10 @@ dependencies = [ name = "originweave-network" version = "0.1.0" dependencies = [ + "base64", "originweave-core", "originweave-destination", + "sha1", ] [[package]] @@ -448,6 +450,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/crates/originweave-network/Cargo.toml b/crates/originweave-network/Cargo.toml index 3d800d8de..68ac4fc8c 100644 --- a/crates/originweave-network/Cargo.toml +++ b/crates/originweave-network/Cargo.toml @@ -11,8 +11,10 @@ homepage.workspace = true publish = false [dependencies] +base64 = "0.22.1" originweave-core = { path = "../originweave-core" } originweave-destination = { path = "../originweave-destination" } +sha1 = "0.10.6" [lints] workspace = true diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index fc72c341d..d42e321ec 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -6,8 +6,9 @@ //! It also bridges a session-correlated WebDriver BiDi loopback target from //! `originweave-core` into one bounded exact TCP connection, binds an RFC 6455 //! opening request to that verified plain stream, and can write that exact request -//! under one bounded deadline without claiming a completed WebSocket handshake or -//! granting browser, WebSocket, TLS, policy, or Agent authority. +//! under one bounded deadline and validate its bounded RFC 6455 opening response +//! without implementing WebSocket framing or granting browser, WebSocket, TLS, +//! policy, or Agent authority. #![forbid(unsafe_code)] #![deny(missing_docs)] @@ -25,7 +26,9 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; pub use webdriver_bidi_websocket_handshake::{ + MAX_WEBSOCKET_OPENING_RESPONSE_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakeError, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketOpeningRequestSent, WebDriverBiDiWebSocketOpeningWriteError, }; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index d8ca9bd98..332fc2e82 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -1,16 +1,21 @@ use std::{ error::Error, fmt, - io::{self, Write}, + io::{self, Read, Write}, net::TcpStream, + thread, time::{Duration, Instant}, }; +use base64::{Engine, engine::general_purpose::STANDARD}; use originweave_core::VerifiedWebDriverBiDiSocketPeer; +use sha1::{Digest, Sha1}; use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; +const RFC6455_WEBSOCKET_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const MAX_WEBSOCKET_OPENING_RESPONSE_BYTES: usize = 16 * 1024; /// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. /// @@ -18,6 +23,18 @@ const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; /// already bounded before this budget is applied. Callers may choose any smaller nonzero deadline. pub const MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT: Duration = Duration::from_secs(5); +/// Maximum wall-clock budget accepted for reading one bounded WebSocket opening response. +/// +/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. Callers may +/// choose any smaller nonzero deadline. +pub const MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Maximum bytes admitted while reading one WebSocket HTTP opening response. +/// +/// The response is consumed only through its terminating `CRLF CRLF`; WebSocket frames are not +/// read or interpreted by this boundary. +pub const MAX_WEBSOCKET_OPENING_RESPONSE_SIZE: usize = MAX_WEBSOCKET_OPENING_RESPONSE_BYTES; + fn is_base64_data_byte(byte: u8) -> bool { byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') } @@ -244,6 +261,537 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { pub const fn write_timeout(&self) -> Duration { self.write_timeout } + + /// Read and validate the bounded RFC 6455 server opening response on this exact stream. + /// + /// Success proves only an HTTP/1.1 `101 Switching Protocols` response with the required + /// `Upgrade`, `Connection`, and client-key-correlated `Sec-WebSocket-Accept` headers. The + /// response body, WebSocket frames, browser process identity, TLS, and browser/Agent authority + /// remain separate boundaries. + pub fn read_opening_response( + self, + response_timeout: Duration, + ) -> Result + { + if response_timeout.is_zero() || response_timeout > MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { + response_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, + }, + ); + } + + let Self { + mut stream, + transport_evidence, + client_key, + request_byte_count, + write_timeout, + } = self; + let mut now = Instant::now; + let (response_status, response_byte_count) = + read_opening_response_with_clock(&mut stream, &client_key, response_timeout, &mut now)?; + + Ok(WebDriverBiDiWebSocketEstablished { + stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + }) + } +} + +/// A live verified stream after both RFC 6455 opening messages were validated. +/// +/// This state does not implement WebSocket framing or grant browser, page, policy, or Agent +/// authority. It retains the exact transport evidence and client key so later protocol stages can +/// remain correlated with the verified peer and opening handshake. +pub struct WebDriverBiDiWebSocketEstablished { + pub(crate) stream: TcpStream, + transport_evidence: WebDriverBiDiTcpConnectionEvidence, + client_key: WebDriverBiDiWebSocketClientKey, + response_status: u16, + response_byte_count: usize, + response_timeout: Duration, + request_byte_count: usize, + write_timeout: Duration, +} + +impl fmt::Debug for WebDriverBiDiWebSocketEstablished { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketEstablished") + .field("stream_local_addr", &self.stream.local_addr().ok()) + .field("transport_evidence", &self.transport_evidence) + .field( + "client_key", + &"", + ) + .field("response_status", &self.response_status) + .field("response_byte_count", &self.response_byte_count) + .field("response_timeout", &self.response_timeout) + .field("request_byte_count", &self.request_byte_count) + .field("write_timeout", &self.write_timeout) + .finish() + } +} + +impl WebDriverBiDiWebSocketEstablished { + /// Borrow the exact verified transport evidence retained with this live stream. + #[must_use] + pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { + &self.transport_evidence + } + + /// Borrow the exact client key correlated with the validated server accept value. + #[must_use] + pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { + &self.client_key + } + + /// Return the validated HTTP status code, currently always `101` on success. + #[must_use] + pub const fn response_status(&self) -> u16 { + self.response_status + } + + /// Return the number of HTTP opening-response bytes consumed through its header terminator. + #[must_use] + pub const fn response_byte_count(&self) -> usize { + self.response_byte_count + } + + /// Return the total response deadline configured for this opening response. + #[must_use] + pub const fn response_timeout(&self) -> Duration { + self.response_timeout + } + + /// Return the number of request bytes written before the response was read. + #[must_use] + pub const fn request_byte_count(&self) -> usize { + self.request_byte_count + } + + /// Return the total write deadline configured for the preceding opening request. + #[must_use] + pub const fn write_timeout(&self) -> Duration { + self.write_timeout + } +} + +/// Fail-closed errors while reading one bounded WebDriver BiDi WebSocket opening response. +#[derive(Debug)] +pub enum WebDriverBiDiWebSocketHandshakeResponseError { + /// The requested total response deadline was zero or above the reviewed resource ceiling. + InvalidResponseTimeout { + /// Rejected caller-supplied deadline. + response_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The monotonic total response deadline elapsed before validation completed. + ResponseDeadlineExceeded { + /// Number of response bytes consumed before the deadline elapsed. + bytes_read: usize, + }, + /// The response exceeded the reviewed header-size ceiling before its terminator was found. + ResponseTooLarge { + /// Number of response bytes consumed before rejection. + bytes_read: usize, + /// Maximum response bytes admitted by this boundary. + maximum_bytes: usize, + }, + /// Applying the operation-local nonblocking read mode failed. + ResponseReadModeConfigurationFailed { + /// Number of response bytes consumed before configuration failed. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket read timed out before the opening response was complete. + ResponseReadTimedOut { + /// Number of response bytes consumed before the timed-out operation. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A non-recoverable socket read failed before the opening response was complete. + ResponseReadFailed { + /// Number of response bytes consumed before the failure. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// The peer closed the stream before sending a complete HTTP header block. + ResponseEndedBeforeHeaders { + /// Number of response bytes consumed before the peer closed the stream. + bytes_read: usize, + }, + /// The HTTP response was not a valid, required WebSocket opening response. + MalformedResponse { + /// Stable, non-secret reason for the rejected response shape. + reason: &'static str, + }, + /// The response's `Sec-WebSocket-Accept` did not correlate with the sent client key. + AcceptMismatch, + /// Restoring blocking mode failed after validation. + ReadModeCleanupFailed { + /// Underlying operating-system error. + source: io::Error, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketHandshakeResponseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidResponseTimeout { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response timeout is outside the reviewed bound", + ), + Self::ResponseDeadlineExceeded { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response exceeded its monotonic deadline", + ), + Self::ResponseTooLarge { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response exceeded its bounded header size", + ), + Self::ResponseReadModeConfigurationFailed { .. } => formatter.write_str( + "failed to configure bounded nonblocking WebDriver BiDi WebSocket response reads", + ), + Self::ResponseReadTimedOut { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response timed out before completion", + ), + Self::ResponseReadFailed { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response read failed before completion", + ), + Self::ResponseEndedBeforeHeaders { .. } => formatter.write_str( + "WebDriver BiDi WebSocket peer ended the stream before completing response headers", + ), + Self::MalformedResponse { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response was malformed or missing a required header", + ), + Self::AcceptMismatch => formatter.write_str( + "WebDriver BiDi WebSocket opening response accept value did not match the client key", + ), + Self::ReadModeCleanupFailed { .. } => formatter.write_str( + "failed to restore blocking WebDriver BiDi WebSocket response reads before handoff", + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketHandshakeResponseError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::ResponseReadModeConfigurationFailed { source, .. } + | Self::ResponseReadTimedOut { source, .. } + | Self::ResponseReadFailed { source, .. } + | Self::ReadModeCleanupFailed { source } => Some(source), + Self::InvalidResponseTimeout { .. } + | Self::ResponseDeadlineExceeded { .. } + | Self::ResponseTooLarge { .. } + | Self::ResponseEndedBeforeHeaders { .. } + | Self::MalformedResponse { .. } + | Self::AcceptMismatch => None, + } + } +} + +struct ParsedOpeningResponse { + status_code: u16, + byte_count: usize, +} + +fn expected_accept_value(client_key: &WebDriverBiDiWebSocketClientKey) -> String { + let mut digest = Sha1::new(); + digest.update(client_key.as_str().as_bytes()); + digest.update(RFC6455_WEBSOCKET_GUID); + STANDARD.encode(digest.finalize()) +} + +fn is_http_token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) +} + +fn has_header_token(value: &str, expected: &str) -> bool { + value + .split(',') + .map(str::trim) + .any(|token| token.eq_ignore_ascii_case(expected)) +} + +#[allow(clippy::collapsible_if)] +fn parse_opening_response( + response: &[u8], + client_key: &WebDriverBiDiWebSocketClientKey, +) -> Result { + if !response.ends_with(b"\r\n\r\n") { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response is missing its CRLF header terminator", + }, + ); + } + let response_text = std::str::from_utf8(response).map_err(|_| { + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response headers are not valid UTF-8", + } + })?; + let header_text = &response_text[..response_text.len() - 4]; + let (status_line, header_lines) = header_text + .split_once("\r\n") + .map_or((header_text, ""), |(line, rest)| (line, rest)); + if status_line.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "status line contains a control byte", + }, + ); + } + let status_code = status_line + .strip_prefix("HTTP/1.1 ") + .and_then(|rest| rest.split_whitespace().next()) + .and_then(|value| value.parse::().ok()); + if status_code != Some(101) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "status line is not HTTP/1.1 101", + }, + ); + } + + let mut upgrade = None; + let mut connection = None; + let mut accept = None; + for line in header_lines.split("\r\n") { + if line.is_empty() + || line + .as_bytes() + .first() + .is_some_and(|byte| matches!(byte, b' ' | b'\t')) + { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header line is empty or folded", + }, + ); + } + let (name, value) = line.split_once(':').ok_or( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header line has no colon", + }, + )?; + if name.is_empty() || !name.bytes().all(is_http_token_byte) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header name is not an HTTP token", + }, + ); + } + let value = value.trim_matches([' ', '\t']); + if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header value contains a control byte", + }, + ); + } + if name.eq_ignore_ascii_case("upgrade") { + if upgrade.is_some() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response repeats the Upgrade header", + }, + ); + } + upgrade = Some(value); + } else if name.eq_ignore_ascii_case("connection") { + if connection.is_some() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response repeats the Connection header", + }, + ); + } + connection = Some(value); + } else if name.eq_ignore_ascii_case("sec-websocket-accept") { + if accept.is_some() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response repeats the Sec-WebSocket-Accept header", + }, + ); + } + accept = Some(value); + } + } + + if !upgrade.is_some_and(|value| has_header_token(value, "websocket")) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "Upgrade header does not contain websocket", + }, + ); + } + if !connection.is_some_and(|value| has_header_token(value, "upgrade")) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "Connection header does not contain Upgrade", + }, + ); + } + let Some(accept) = accept else { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response has no Sec-WebSocket-Accept header", + }, + ); + }; + if accept != expected_accept_value(client_key) { + return Err(WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch); + } + + Ok(ParsedOpeningResponse { + status_code: 101, + byte_count: response.len(), + }) +} + +trait OpeningResponseReader { + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()>; + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result; +} + +impl OpeningResponseReader for TcpStream { + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { + TcpStream::set_nonblocking(self, nonblocking) + } + + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { + self.read(bytes) + } +} + +fn read_opening_response_with_clock( + reader: &mut dyn OpeningResponseReader, + client_key: &WebDriverBiDiWebSocketClientKey, + response_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { + let deadline = now() + response_timeout; + let mut response = Vec::new(); + + reader.set_nonblocking(true).map_err(|source| { + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 0, + source, + } + })?; + + loop { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: response.len(), + }, + ); + } + if response.len() >= MAX_WEBSOCKET_OPENING_RESPONSE_BYTES { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { + bytes_read: response.len(), + maximum_bytes: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + }, + ); + } + let mut byte = [0_u8; 1]; + match reader.read_response_bytes(&mut byte) { + Ok(0) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { + bytes_read: response.len(), + }, + ); + } + Ok(1) => { + response.push(byte[0]); + if response.ends_with(b"\r\n\r\n") { + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: response.len(), + }, + ); + } + let parsed = parse_opening_response(&response, client_key)?; + reader.set_nonblocking(false).map_err(|source| { + WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { + source, + } + })?; + return Ok((parsed.status_code, parsed.byte_count)); + } + } + Ok(_) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: response.len(), + source: io::Error::new( + io::ErrorKind::InvalidData, + "response reader returned more bytes than requested", + ), + }, + ); + } + Err(source) if source.kind() == io::ErrorKind::Interrupted => {} + Err(source) + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { + bytes_read: response.len(), + source, + }, + ); + } + thread::sleep(Duration::from_millis(1)); + } + Err(source) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: response.len(), + source, + }, + ); + } + } + } } /// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. @@ -395,19 +943,19 @@ fn write_request_with_clock( ); } } - Err(source) if source.kind() == io::ErrorKind::Interrupted => {} - Err(source) + Err(source) => { + if source.kind() == io::ErrorKind::Interrupted { + continue; + } if matches!( source.kind(), io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) => - { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written, - source, - }); - } - Err(source) => { + ) { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written, + source, + }); + } return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { bytes_written, source, @@ -482,6 +1030,281 @@ mod opening_write_tests { } } + #[derive(Clone, Debug)] + enum ReadAction { + Byte(u8), + Count(usize), + End, + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeReader { + actions: VecDeque, + mode_error: Option, + cleanup_error: Option, + } + + impl FakeReader { + fn new(actions: impl IntoIterator) -> Self { + Self { + actions: actions.into_iter().collect(), + mode_error: None, + cleanup_error: None, + } + } + } + + impl OpeningResponseReader for FakeReader { + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { + let error = if nonblocking { + self.mode_error + } else { + self.cleanup_error + }; + error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) + } + + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { + match self.actions.pop_front().unwrap_or(ReadAction::End) { + ReadAction::Byte(byte) => { + bytes[0] = byte; + Ok(1) + } + ReadAction::Count(count) => Ok(count), + ReadAction::End => Ok(0), + ReadAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + fn client_key() -> WebDriverBiDiWebSocketClientKey { + WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ==") + .expect("test client key must be valid") + } + + fn valid_response() -> Vec { + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec() + } + + fn byte_actions(bytes: &[u8]) -> Vec { + bytes.iter().copied().map(ReadAction::Byte).collect() + } + + fn is_malformed_response(response: &[u8], key: &WebDriverBiDiWebSocketClientKey) -> bool { + matches!( + parse_opening_response(response, key), + Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { .. }) + ) + } + + fn read_with_fake( + reader: &mut FakeReader, + now_values: impl IntoIterator, + ) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { + let key = client_key(); + let fallback = Instant::now(); + let mut now_values = now_values.into_iter(); + let mut now = || now_values.next().unwrap_or(fallback); + read_opening_response_with_clock(reader, &key, Duration::from_secs(1), &mut now) + } + + #[test] + fn parser_accepts_case_insensitive_upgrade_tokens_and_rejects_malformed_headers() { + let key = client_key(); + let response = b"HTTP/1.1 101 Switching Protocols\r\nUpGrAdE: WebSocket\r\nConnection: keep-alive, Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nX-Test: retained\r\n\r\n"; + let parsed = parse_opening_response(response, &key).expect("valid response"); + assert_eq!(parsed.status_code, 101); + assert_eq!(parsed.byte_count, response.len()); + assert!(!is_malformed_response(response, &key)); + + let malformed_responses = [ + b"HTTP/1.1 101".to_vec(), + vec![0xff, b'\r', b'\n', b'\r', b'\n'], + b"HTTP/1.1 101\0 Switching Protocols\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nBad Header: value\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: web\x01socket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nUpgrade: websocket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nConnection: Upgrade\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nSec-WebSocket-Accept: one\r\nSec-WebSocket-Accept: two\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: h2c\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: keep-alive\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n".to_vec(), + ]; + for response in malformed_responses { + assert!(is_malformed_response(&response, &key)); + } + } + + #[test] + fn bounded_response_reader_covers_deadlines_size_io_and_cleanup() { + let start = Instant::now(); + + let mut valid_reader = FakeReader::new(byte_actions(&valid_response())); + let valid = read_with_fake(&mut valid_reader, [start]); + assert!(matches!(valid, Ok((101, 129)))); + + let mut interrupted_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) + .chain(byte_actions(&valid_response())), + ); + assert!(read_with_fake(&mut interrupted_reader, [start]).is_ok()); + + let mut mode_error_reader = FakeReader::new([]); + mode_error_reader.mode_error = Some(io::ErrorKind::InvalidInput); + assert!(matches!( + read_with_fake(&mut mode_error_reader, [start]), + Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 0, + .. + } + ) + )); + + let mut ended_reader = FakeReader::new([ReadAction::End]); + assert!(matches!( + read_with_fake(&mut ended_reader, [start]), + Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { + bytes_read: 0 + } + ) + )); + + let mut count_reader = FakeReader::new([ReadAction::Count(2)]); + assert!(matches!( + read_with_fake(&mut count_reader, [start]), + Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: 0, + .. + } + ) + )); + + let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); + assert!(matches!( + read_with_fake(&mut failed_reader, [start]), + Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: 0, + .. + } + ) + )); + + let mut retrying_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::WouldBlock)) + .chain(byte_actions(&valid_response())), + ); + assert!(read_with_fake(&mut retrying_reader, [start]).is_ok()); + + let mut timed_out_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::TimedOut)]); + assert!(matches!( + read_with_fake( + &mut timed_out_reader, + [start, start, start + Duration::from_secs(1)] + ), + Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { + bytes_read: 0, + .. + } + ) + )); + + let mut deadline_reader = FakeReader::new([ReadAction::End]); + assert!(matches!( + read_with_fake( + &mut deadline_reader, + [start, start + Duration::from_secs(1)] + ), + Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: 0 + } + ) + )); + + let mut late_response_reader = FakeReader::new(byte_actions(&valid_response())); + let mut late_response_times = vec![start; valid_response().len() + 1]; + late_response_times.push(start + Duration::from_secs(1)); + assert!(matches!( + read_with_fake(&mut late_response_reader, late_response_times), + Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { .. }) + )); + + let mut cleanup_reader = FakeReader::new(byte_actions(&valid_response())); + cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); + assert!(matches!( + read_with_fake(&mut cleanup_reader, [start]), + Err(WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { .. }) + )); + + let mut too_large_reader = FakeReader::new(std::iter::repeat_n( + ReadAction::Byte(b'a'), + MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + )); + assert!(matches!( + read_with_fake(&mut too_large_reader, [start]), + Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { + bytes_read: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + maximum_bytes: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + } + ) + )); + } + + #[test] + fn response_errors_have_deterministic_messages_and_sources() { + let source = io::Error::from(io::ErrorKind::InvalidInput); + let errors = [ + WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { + response_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { + bytes_read: 1, + maximum_bytes: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { + bytes_read: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "test" }, + WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch, + WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { source }, + ]; + for (error, has_source) in errors.iter().zip([ + false, false, false, true, true, true, false, false, false, true, + ]) { + assert!(!error.to_string().is_empty()); + assert_eq!(error.source().is_some(), has_source); + } + } + #[test] fn bounded_writer_completes_partial_and_interrupted_writes() { let mut writer = FakeWriter::new([ diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs index 433774dd4..9d688cd2f 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs @@ -1,5 +1,5 @@ use std::{ - io::{self, Read}, + io::{self, Read, Write}, net::TcpListener, thread, time::Duration, @@ -9,7 +9,7 @@ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketOpeningWriteError, + WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketOpeningWriteError, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -171,3 +171,158 @@ fn opening_write_rejects_zero_and_excessive_deadlines_before_success_evidence() } } } + +#[test] +fn opening_response_requires_rfc6455_switching_protocols_and_matching_accept() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let mut close_probe = [0_u8; 1]; + let _ = stream.read(&mut close_probe); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + + let established = written.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + assert_eq!(established.response_status(), 101); + assert!(established.response_byte_count() > 0); + assert!(established.request_byte_count() > 0); + assert_eq!(established.response_timeout(), Duration::from_millis(500)); + assert_eq!(established.write_timeout(), Duration::from_millis(500)); + assert_eq!(established.client_key().as_str(), RFC6455_SAMPLE_KEY); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .socket_addr(), + local_addr + ); + let debug = format!("{established:?}"); + assert!(debug.contains("WebDriverBiDiWebSocketEstablished")); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); + drop(established); + assert!(server.join().is_ok()); +} + +#[test] +fn opening_response_rejects_a_mismatched_accept_value() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: invalid\r\n\r\n", + ) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + + assert!(matches!( + written.read_opening_response(Duration::from_millis(500)), + Err(WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch) + )); + assert!(server.join().is_ok()); +} + +#[test] +fn opening_response_rejects_zero_and_excessive_deadlines_before_socket_mode_change() { + for timeout in [ + Duration::ZERO, + originweave_network::MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT + Duration::from_nanos(1), + ] { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + continue; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + continue; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + continue; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + continue; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + continue; + }; + + assert!(matches!( + written.read_opening_response(timeout), + Err(WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { + response_timeout, + maximum_timeout, + }) if response_timeout == timeout + && maximum_timeout + == originweave_network::MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT + )); + assert!(server.join().is_ok()); + } +} diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index dbf3ef731..a50323f13 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -1,10 +1,10 @@ # Browser and Agent Protocol Standards Evidence -- **Reviewed:** 2026-08-18 +- **Reviewed:** 2026-08-20 - **Purpose:** primary-source evidence for OriginWeave browser compatibility and adapter boundaries - **Canonical research index:** [`../doctoring.md`](../doctoring.md) -This addendum complements the main doctoring record. The main record already carries the WebDriver BiDi, WARC/ISO 28500 and W3C PROV-O evidence. This addendum records the current primary sources for Manifest V3, Chrome DevTools Protocol, WebMCP and Model Context Protocol so product documentation does not rely on uncited protocol names. +This addendum complements the main doctoring record. The main record already carries the WebDriver BiDi, WARC/ISO 28500 and W3C PROV-O evidence. This addendum records the current primary sources for RFC 6455, Manifest V3, Chrome DevTools Protocol, WebMCP and Model Context Protocol so product documentation does not rely on uncited protocol names. ## WebDriver BiDi @@ -18,6 +18,12 @@ The same reviewed Editor’s Draft defines a closed `ErrorCode` vocabulary that Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). +## RFC 6455 WebSocket opening handshake + +RFC 6455 requires a client opening request to carry a fresh `Sec-WebSocket-Key` and requires a server upgrade response to return HTTP `101`, the `Upgrade: websocket` and `Connection: Upgrade` tokens, and a `Sec-WebSocket-Accept` value derived from that exact client key and the fixed WebSocket GUID. OriginWeave now validates this bounded response on the already peer-verified stream, with duplicate/security-header rejection, a response-size ceiling, and a monotonic deadline. This proves only the RFC 6455 opening exchange; it does not authenticate a browser process, implement WebSocket frames, or grant browser/Agent authority. + +Primary source: Internet Engineering Task Force, *The WebSocket Protocol* (RFC 6455). + ## Chrome Manifest V3 Chrome's current manifest documentation identifies Manifest V3 as the current extension manifest format and the supported `manifest_version` value. OriginWeave therefore tests its declared extension compatibility against a pinned real Chromium/Chrome-for-Testing build and publishes evidence by exact capability. This is a compatibility target, not a claim of universal Chrome/Web Store/Google-service/codec/DRM equivalence. @@ -89,4 +95,6 @@ World Wide Web Consortium. (2026, July 20). *WebDriver BiDi* (Editor’s Draft). World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ +Fette, I., & Melnikov, A. (2011). *The WebSocket protocol* (RFC 6455). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc6455 + International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html From 924f260cac885a8c66c81de1101c1ba183d00e74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 21:29:21 +0900 Subject: [PATCH 150/570] test(network): close handshake coverage gaps --- .../src/webdriver_bidi_websocket_handshake.rs | 93 +++++-------------- 1 file changed, 25 insertions(+), 68 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 332fc2e82..9aacf0e02 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -1117,6 +1117,13 @@ mod opening_write_tests { assert_eq!(parsed.status_code, 101); assert_eq!(parsed.byte_count, response.len()); assert!(!is_malformed_response(response, &key)); + let same_length_mismatch = String::from_utf8(response.to_vec()) + .expect("valid response fixture") + .replace( + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", + "s3pPLMBiTxaQ9kYGzzhZRbK+xOoX", + ); + assert!(parse_opening_response(same_length_mismatch.as_bytes(), &key).is_err()); let malformed_responses = [ b"HTTP/1.1 101".to_vec(), @@ -1127,6 +1134,7 @@ mod opening_write_tests { b"HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n\r\n".to_vec(), b"HTTP/1.1 101 Switching Protocols\r\nUpgrade\r\n\r\n".to_vec(), b"HTTP/1.1 101 Switching Protocols\r\nBad Header: value\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n: value\r\n\r\n".to_vec(), b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: web\x01socket\r\n\r\n".to_vec(), b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nUpgrade: websocket\r\n\r\n".to_vec(), b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nConnection: Upgrade\r\n\r\n".to_vec(), @@ -1148,7 +1156,10 @@ mod opening_write_tests { let mut valid_reader = FakeReader::new(byte_actions(&valid_response())); let valid = read_with_fake(&mut valid_reader, [start]); - assert!(matches!(valid, Ok((101, 129)))); + assert!(valid.is_ok()); + + let mut malformed_reader = FakeReader::new(byte_actions(b"HTTP/1.1 200 OK\r\n\r\n")); + assert!(read_with_fake(&mut malformed_reader, [start]).is_err()); let mut interrupted_reader = FakeReader::new( std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) @@ -1158,47 +1169,16 @@ mod opening_write_tests { let mut mode_error_reader = FakeReader::new([]); mode_error_reader.mode_error = Some(io::ErrorKind::InvalidInput); - assert!(matches!( - read_with_fake(&mut mode_error_reader, [start]), - Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { - bytes_read: 0, - .. - } - ) - )); + assert!(read_with_fake(&mut mode_error_reader, [start]).is_err()); let mut ended_reader = FakeReader::new([ReadAction::End]); - assert!(matches!( - read_with_fake(&mut ended_reader, [start]), - Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { - bytes_read: 0 - } - ) - )); + assert!(read_with_fake(&mut ended_reader, [start]).is_err()); let mut count_reader = FakeReader::new([ReadAction::Count(2)]); - assert!(matches!( - read_with_fake(&mut count_reader, [start]), - Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { - bytes_read: 0, - .. - } - ) - )); + assert!(read_with_fake(&mut count_reader, [start]).is_err()); let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); - assert!(matches!( - read_with_fake(&mut failed_reader, [start]), - Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { - bytes_read: 0, - .. - } - ) - )); + assert!(read_with_fake(&mut failed_reader, [start]).is_err()); let mut retrying_reader = FakeReader::new( std::iter::once(ReadAction::Error(io::ErrorKind::WouldBlock)) @@ -1207,60 +1187,37 @@ mod opening_write_tests { assert!(read_with_fake(&mut retrying_reader, [start]).is_ok()); let mut timed_out_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::TimedOut)]); - assert!(matches!( + assert!( read_with_fake( &mut timed_out_reader, [start, start, start + Duration::from_secs(1)] - ), - Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { - bytes_read: 0, - .. - } ) - )); + .is_err() + ); let mut deadline_reader = FakeReader::new([ReadAction::End]); - assert!(matches!( + assert!( read_with_fake( &mut deadline_reader, [start, start + Duration::from_secs(1)] - ), - Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { - bytes_read: 0 - } ) - )); + .is_err() + ); let mut late_response_reader = FakeReader::new(byte_actions(&valid_response())); let mut late_response_times = vec![start; valid_response().len() + 1]; late_response_times.push(start + Duration::from_secs(1)); - assert!(matches!( - read_with_fake(&mut late_response_reader, late_response_times), - Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { .. }) - )); + assert!(read_with_fake(&mut late_response_reader, late_response_times).is_err()); let mut cleanup_reader = FakeReader::new(byte_actions(&valid_response())); cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); - assert!(matches!( - read_with_fake(&mut cleanup_reader, [start]), - Err(WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { .. }) - )); + assert!(read_with_fake(&mut cleanup_reader, [start]).is_err()); let mut too_large_reader = FakeReader::new(std::iter::repeat_n( ReadAction::Byte(b'a'), MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, )); - assert!(matches!( - read_with_fake(&mut too_large_reader, [start]), - Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { - bytes_read: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, - maximum_bytes: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, - } - ) - )); + assert!(read_with_fake(&mut too_large_reader, [start]).is_err()); } #[test] From d9ba1fa2315b76c83daca956c370ceb4dc2e21ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:14:30 -0700 Subject: [PATCH 151/570] test(docs): require commercial completion tracks --- tests/test_product_completion_gap_contract.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/test_product_completion_gap_contract.py diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py new file mode 100644 index 000000000..a3dc6e2ff --- /dev/null +++ b/tests/test_product_completion_gap_contract.py @@ -0,0 +1,49 @@ +"""Regression contract for the dated commercial-completion gap baseline.""" + +from __future__ import annotations + +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +BASELINE = ROOT / "docs/product-technical-gap-baseline.md" + + +class ProductCompletionGapContractTests(unittest.TestCase): + """Keep the exact repository snapshot and completion tracks reviewable.""" + + def test_baseline_records_current_inventory_and_completion_issues(self) -> None: + """The dated baseline must not retain superseded queue counts or omit buyer tracks.""" + text = BASELINE.read_text(encoding="utf-8") + + for phrase in ( + "145 open pull requests", + "38 non-draft", + "107 draft", + "#198", + "#199", + "#200", + "#201", + "#202", + "#203", + "durable WARC/PROV replay", + "stable BAP/MCP runtime API", + "signed cross-platform Chromium distribution", + "enterprise control and experience plane", + "commercial acceptance gate", + ): + with self.subTest(phrase=phrase): + self.assertIn(phrase, text) + + for stale_phrase in ( + "100 open pull requests", + "22 non-draft", + "78 draft", + "79 draft PRs", + ): + with self.subTest(stale_phrase=stale_phrase): + self.assertNotIn(stale_phrase, text) + + +if __name__ == "__main__": + unittest.main() From a44e28f226cff0a750c25960eca697cb9ce85086 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:21:16 -0700 Subject: [PATCH 152/570] docs: map commercial completion gaps --- docs/product-technical-gap-baseline.md | 108 ++++++++++++++++--------- 1 file changed, 72 insertions(+), 36 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 02bef5205..b63c28e09 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,80 +1,116 @@ # Product and Technical Gap Baseline -This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, or architecture decisions. It keeps buyer-visible gaps and volatile repository evidence in one discoverable place. Protected `main` is the implementation boundary: code in an open pull request is not shipped behavior. +This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, architecture decisions, or live GitHub state. It keeps buyer-visible gaps, current issues, active pull-request evidence, and commercial completion tracks in one discoverable place. Protected `main` is the implementation boundary: code in an open pull request is not shipped behavior. ## Observed snapshot: 2026-08-20 ### Protected-main truth -- Protected `main` and `origin/main` were both at `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` when this snapshot was prepared. -- Phase 0 is documented as complete as a reusable safety-kernel foundation: typed policy contracts, destination classification, direct TCP peer verification, TLS service identity, evidence bounds, resource mitigation, document-node authority, and their protected-main tests. -- Phase 1 is **in progress**, not shipped. The first real Chromium vertical slice still needs an ephemeral Chromium context, a versioned WebDriver BiDi/browser adapter, semantic observation and typed actions, post-condition evidence, crash recovery, and proof that Chromium consumed the governed resolution, route, TCP, TLS, and HTTP boundaries. -- HTTP/1.1 bounds, download/MIME limits, proxy/PAC execution, full browser-network integration, the sensitive-data broker runtime, durable WARC/PROV capture, and the complete Manifest V3 compatibility program remain planned or open as recorded in the PRD, TRD, and roadmap. +- Protected `main` was at `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` when this snapshot was refreshed. +- Phase 0 is documented as complete as a reusable safety-kernel foundation: typed policy contracts, destination classification, direct TCP peer verification, TLS service identity, evidence bounds, resource mitigation, document-node authority, and protected-main tests. +- Phase 1 is **in progress**, not shipped. The first real Chromium vertical slice still needs the active WebDriver BiDi transport stack to reach protected `main`, then compose isolated Chromium launch, session/context identity, semantic observation, typed action authorization, native browser input, post-condition proof, evidence, cancellation, crash recovery, and profile/process teardown. +- HTTP/1.1 bounds, downloads/MIME, proxy/PAC consumption, full browser-network integration, the sensitive-data broker runtime, durable WARC/PROV capture, persistent task/API surfaces, signed cross-platform distribution, enterprise administration, and release-grade buyer acceptance remain open. +- Active pull requests remain evidence, not shipped behavior. Successful checks on a feature or stacked branch do not prove that protected `main` contains the capability or that a child can merge before its prerequisite. ### Open pull requests -The live repository contained **100 open pull requests: 22 non-draft and 78 draft**. The non-draft set was: +The live repository contained **145 open pull requests: 38 non-draft and 107 draft**. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. -| Pull request | Base | Delivery state at snapshot | -|---|---|---| -| #194, #175, #173, #168, #166, #164, #163, #161, #160, #159, #158, #157, #156, #152, #124 | `main` | Ready/non-draft inventory; current review and check state must be re-fetched before merge | -| #149 | `main` | WireGuard/IKEv2 profile authority; exact head `b2be2e7`, Rust contracts and Production coverage successful, remaining required workflows were queued | -| #196 | `main` | This product/technical gap baseline; checks were queued after publication and merge remains review-gated | -| #153, #151, #150, #148, #147 | stacked | Non-draft teardown/crash-recovery work; base-branch ordering applies | +Representative active workstreams at this snapshot were: -Draft PR #195 is the current WebDriver BiDi opening-write repair. Its exact head is `4ff33b5`; Rust contracts and Production coverage were re-running after the test-only coverage repair. It remains draft evidence and cannot be treated as shipped behavior. +| Workstream | Representative active PR evidence | Delivery boundary | +|---|---|---| +| Product baseline | #196 | Ready/non-draft documentation PR; this refreshed inventory and the completion issues below remain review-gated | +| WebDriver BiDi transport | #188 through #198 | #198, exact head `924f260cac885a8c66c81de1101c1ba183d00e74`, validates the RFC 6455 opening response on top of #195; the stack still does not by itself complete framed BiDi browser commands, authenticated browser-process provenance, semantic task execution, or protected-main shipment | +| MCP adapter | #168 and #170 | Typed MCP routing and conservative `tools/list` metadata are active-PR foundations; complete authenticated transport, durable task lifecycle, cancellation/resume, and browser execution remain open under #200 | +| Controlled Chromium and recovery | #65, #70-#73, #100, #105, #142-#153 and descendants | Real pinned-browser fixture, semantic location, resource, crash, and teardown evidence exists on active stacks; evidence does not transfer across heads or prerequisites | +| Manifest V3 and native messaging | #27 and its active extension/native-host stack, including #154 and #169 | Compatibility and Agent-authority isolation remain incomplete until exact release artifacts and platform matrices are proven | +| Sensitive-data and model route policy | #10 and its active policy stacks | Deterministic policy values exist, but trusted broker execution, retention/deletion, runtime isolation, and auditable product workflows remain open | +| VPN/profile intent | #149 | Bounded WireGuard/IKEv2 profile authority is active-PR evidence; it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof | -The snapshot also retained an older open failure on #90 (`8721787d`): Rust contracts were successful but Production coverage was failing. That PR is not a protected-main implementation claim. The current exact head and check runs must be re-fetched before any action. +Draft PR #198 is the current top WebDriver BiDi opening-response slice; its prerequisite #195 owns the bounded opening-request write. It remains draft evidence and cannot be treated as shipped behavior. -The 79 draft PRs are intentionally excluded from the merge queue. Several open PRs are stacked, so a green check on a child branch cannot be treated as evidence that its change is mergeable onto protected `main`. +The current queue must be processed in dependency order. A green child branch cannot substitute for current checks and review on its prerequisite, synthetic merge, or eventual protected-main commit. PRs that only duplicate, supersede, or preserve stale branch topology should be closed with explicit replacement evidence rather than retained indefinitely. ### Review and merge authority -The active `CWL Central required workflows` ruleset requires one approving review, approval after the last push, resolved review threads, and the configured required workflows. The live collaborator list contained only `seonghobae` with repository administration and push permissions. This is a **reviewer-provisioning gap**: no eligible independent collaborator was available for a counted non-author approval at snapshot time. +The active `CWL Central required workflows` ruleset requires one approving review, approval after the last push, resolved review threads, and configured required workflows. The previously observed collaborator inventory contained only `seonghobae` with administration and push permissions, creating a **reviewer-provisioning gap** for counted non-author approval. -This gap does not authorize self-approval, administrative bypass, stale-head merge, or weakening checks. Exact current-head checks, security gates, documentation, coverage, rustdoc/Clippy, thread resolution, and branch protection remain mandatory. The solo-maintainer governance condition may place an otherwise impossible independent-review rule on hold only through the documented governance path; it does not turn an unverified PR into shipped behavior. +This gap does not authorize self-approval, administrative bypass, stale-head merge, or weaker checks. Exact current-head checks, security gates, complete coverage, rustdoc/Clippy, thread resolution, and branch protection remain mandatory. Before any merge decision, re-fetch the exact ruleset, collaborators, PR head/base, reviews, unresolved threads, and required checks; do not assume this dated observation remains current. ### Open issues and operational signals | Issue | Current gap or signal | |---|---| -| #28 | First real Chromium agent vertical slice; highest buyer-visible Phase 1 gap | +| #28 | First real Chromium Agent Task vertical slice; highest immediate Phase 1 buyer-visible gap | | #27 | Complete Manifest V3 compatibility and extension-authority isolation matrix | | #9 | Bounded HTTP/1.1 semantics over the authenticated TLS stream | | #10 | Purpose-bound operational PII disclosure and trusted broker/storage lifecycle | | #123 | Fleet incident: disable orphaned TLS, HTTP, and one-shot workflow identities | | #187 | Manual-authority review of the coverage-diagnostics workflow delta | +| #199 | Schema-bound extraction with durable WARC/PROV replay, retention, deletion, and offline verification | +| #200 | Stable BAP/MCP runtime API with authenticated, idempotent, cancellable, resumable task lifecycle | +| #201 | Signed cross-platform Chromium distribution, installer/updater, patch SLA, rollback, SBOM, and provenance | +| #202 | Enterprise control and experience plane: operator UI, Keyverse-compatible identity, tenancy, approval, audit, SLO, Figma, and Storybook | +| #203 | Release-grade web-agent benchmark and commercial acceptance gate bound to exact signed artifacts | + +The five newly separated product-completion tracks are **durable WARC/PROV replay**, **stable BAP/MCP runtime API**, **signed cross-platform Chromium distribution**, **enterprise control and experience plane**, and the **commercial acceptance gate**. They are separate issues because each has a distinct authority, data, release, and buyer-acceptance boundary. -The hourly product-development loop exists as a bounded, review-separated workflow. Its existence is operational infrastructure, not evidence that the browser product or an hourly run has completed the Phase 1 buyer acceptance. +The hourly product-development loop is operational infrastructure, not proof that a browser product, issue, pull request, or release meets buyer acceptance. ## Buyer-visible and technical gap matrix -| Priority | Buyer-visible outcome | Protected-main status | Next acceptance evidence | +| Priority | Buyer-visible outcome | Protected-main status | Completion issue and acceptance evidence | |---|---|---|---| -| P0 | A bounded task can observe a real Chromium page, perform a typed action, verify the post-condition, and emit provenance | **Open / Phase 1**; issue #28 | Repeated real Chromium E2E with ephemeral context, BiDi/session translation, observation, typed action, post-condition, evidence, crash cleanup, and exact current protected checks | -| P1 | Navigation uses the approved destination, route, TCP peer, TLS identity, and bounded HTTP/download policy | **Partial foundation**; HTTP and browser consumption remain planned | Real browser-network adapter proves the governed path is consumed end to end, including redirects, bounds, MIME, and failure evidence | -| P1 | Existing Chromium extensions remain compatible while Agent authority stays separate | **Partial evidence / planned completion**; issue #27 | Pinned-Chromium install/update/service-worker/content/storage/DNR/download/native-messaging/enterprise-isolation matrix with repeatability | -| P1 | Enterprise operators can disclose only necessary sensitive fields through a trusted, auditable path | **Policy foundation implemented; runtime open**; issue #10 | Opaque-handle broker, purpose/field/region policy, atomic reservation/revocation, retention/deletion, audit, and redaction tests | -| P2 | A buyer can receive durable replayable capture and provenance | **Foundations only** | Bounded WARC/PROV persistence, retention, integrity, replay, and benchmark evidence | -| P0 | Changes can pass protected review and merge without authority improvisation | **Blocked by reviewer-provisioning gap** | Provision an eligible independent collaborator or record an explicit current governance decision; then re-fetch exact head, reviews, checks, and merge state | +| P0 | A bounded task observes a real Chromium page, performs one typed action, verifies the post-condition, and emits provenance | **Open / Phase 1** | #28; repeated real Chromium E2E with isolated context, exact session/node authority, typed dispatch, post-condition, crash cleanup, and protected-main checks | +| P0 | Navigation consumes approved origin, resolution, route, TCP peer, TLS identity, bounded HTTP, redirect, MIME, and download policy | **Partial foundation** | #9 plus #28; real browser-network adapter proves the governed path is consumed end to end | +| P1 | Existing Chromium extensions remain compatible while Agent authority stays separate | **Partial active-PR evidence** | #27; exact supported-build/platform compatibility matrix, managed allow-list, native-host isolation, repeatability, and release binding | +| P1 | Authorized work can use necessary PII without ambient exposure | **Policy foundation; runtime open** | #10; opaque broker, exact field/purpose/destination/model policy, atomic use/revocation, retention/deletion, and value-free telemetry | +| P1 | Every released structured field is traceable to replayable source evidence | **Foundations only** | #199; durable WARC/PROV replay, integrity, retention, deletion, offline verification, extraction precision/recall, and 100% provenance completeness | +| P1 | External Agents integrate through a stable, authenticated product contract | **Partial active-PR MCP primitives** | #200; BAP 1.0, MCP 2026-07-28 adapter, idempotency, task cancellation/resume, checkpoint/reconciliation, and SDK conformance | +| P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | +| P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | +| P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 145-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | + +## Commercial completion definition + +OriginWeave is not complete merely because every low-level primitive exists in some open branch. A release candidate is commercially complete only when all of the following are true for the declared support profile: + +1. #9, #10, #27, and #28 are integrated on protected `main` as a complete browser/network/action/evidence chain. +2. #199 provides replayable, retention-governed evidence for every released structured result. +3. #200 exposes a stable authenticated runtime API and task lifecycle without raw Chromium authority leakage. +4. #201 produces signed, updateable, rollback-capable release artifacts bound to Chromium, SBOM, and provenance. +5. #202 supplies tenant-safe enterprise administration, approvals, audit, SLOs, incident recovery, accessible Figma/Storybook-backed UX, and control evidence. +6. #203 accepts the exact signed artifacts through a reproducible benchmark; missing or inconclusive evidence cannot be promoted to success. +7. Production function, line, region, and branch coverage and public API documentation remain exactly complete for OriginWeave-owned code. +8. CHANGELOG, version, supported-platform matrix, security policy, runbooks, licensing, release notes, upgrade/rollback guidance, and procurement evidence match the exact release. +9. No required check, browser/platform lane, security case, benchmark case, or independent review is skipped, stale, inherited, or represented by status-only evidence. +10. The open PR queue is reduced to bounded active work rather than being the only place where the product exists. ## Next executable queue -1. Re-fetch every active PR's exact head, reviews, threads, required checks, and base before selecting a merge candidate; repair a current failure only after reproducing its root cause. -2. Advance issue #28 with the smallest failing real-browser acceptance test, beginning at ephemeral Chromium launch/session teardown and the BiDi adapter boundary. -3. Keep HTTP/1.1 and browser-network integration separate from the already-proven destination, direct TCP, and TLS kernels; do not claim safe navigation until Chromium consumption is observed. -4. Maintain the #27 extension matrix and #10 broker/runtime boundaries as independent acceptance tracks. -5. Resolve the reviewer-provisioning gap through legitimate repository governance before a non-author approval is required; never manufacture approval or bypass protection. +1. Re-fetch all 145 PRs and compute the dependency graph, exact heads/bases, reviews, unresolved threads, current required checks, duplicate/supersession relationships, and branch ancestry. +2. Integrate merge-ready root PRs first; restack and independently revalidate only the immediate children. Close obsolete alternatives instead of carrying parallel truth. +3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #195/#198 WebSocket opening path and the remaining framed BiDi command/response, semantic observation, policy, action, post-condition, and recovery boundaries. +4. Finish #27 and #10 as separate security tracks; neither should be hidden inside the first browser PR. +5. Implement #199, then #200, so durable evidence and stable task authority precede broad enterprise integrations. +6. Implement #201 before making release/support claims; exact CI browser evidence must be bound to the actual signed artifact. +7. Design #202 in Figma, record the Figma File ID in the ADR, implement reusable design tokens and Storybook components, then add identity/tenant/approval/audit/operations integration. +8. Make #203 the final release gate across the exact signed distribution, not a source branch or model narrative. +9. Only after the commercial acceptance gate passes, increment the version, finalize CHANGELOG/release notes, publish signed artifacts, and verify upgrade/rollback from the prior supported release. ## Evidence commands -The volatile values above were obtained from the repository and GitHub APIs, without exposing credentials: +The volatile counts above were obtained from GitHub search rather than the first 100 results of the pull-request list endpoint: ```text -gh api 'repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100' -gh api 'repos/ContextualWisdomLab/OriginWeave/commits/0841d2ab3d8b5e60a03c0a8e818cf438e2716829/check-runs?per_page=100' -gh api 'repos/ContextualWisdomLab/OriginWeave/rulesets/18156473' +gh api search/issues -f q='repo:ContextualWisdomLab/OriginWeave is:pr is:open' +gh api search/issues -f q='repo:ContextualWisdomLab/OriginWeave is:pr is:open draft:true' +gh api search/issues -f q='repo:ContextualWisdomLab/OriginWeave is:pr is:open draft:false' +gh api repos/ContextualWisdomLab/OriginWeave/branches/main +gh api repos/ContextualWisdomLab/OriginWeave/rulesets/18156473 gh api 'repos/ContextualWisdomLab/OriginWeave/collaborators?affiliation=all&per_page=100' ``` -For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and the linked ADR/UML/ERD/traceability graph. This baseline intentionally records delivery state and does not promote planned adapters or open pull-request code to implemented behavior. +For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. From 6251b7043c80ae8df0a39b8423c2f5cac714466c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:24:11 -0700 Subject: [PATCH 153/570] test(docs): require reproducible PR inventory evidence --- tests/test_product_completion_gap_contract.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index a3dc6e2ff..141cb790c 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -44,6 +44,23 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: with self.subTest(stale_phrase=stale_phrase): self.assertNotIn(stale_phrase, text) + def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> None: + """The evidence procedure must paginate the queue and inspect each exact PR head.""" + text = BASELINE.read_text(encoding="utf-8") + evidence = text.split("## Evidence commands", 1)[1] + + for phrase in ( + "--paginate --slurp 'repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100'", + "jq '[.[][]]'", + '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR"', + '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100"', + '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100"', + "reviewThreads(first: 100, after: $endCursor)", + "rulesets/18156473", + ): + with self.subTest(phrase=phrase): + self.assertIn(phrase, evidence) + if __name__ == "__main__": unittest.main() From 1e6c35df6fed66e9c8a0c4cbcc791ff1383fa6b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:26:58 +0900 Subject: [PATCH 154/570] feat(network): add bounded WebSocket frame transport --- CHANGELOG.md | 1 + crates/originweave-network/src/lib.rs | 9 +- .../src/webdriver_bidi_websocket_handshake.rs | 937 +++++++++++++++++- .../webdriver_bidi_websocket_opening_write.rs | 330 +++++- docs/doctoring/browser-agent-protocols.md | 2 +- 5 files changed, 1271 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74e3f7a56..b5d031b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Bounded RFC 6455 frame transport on the established WebDriver BiDi stream: client text frames require a caller-supplied fresh mask key and are masked on the wire, server frames are required to be unmasked, reserved bits/opcodes and nonminimal lengths fail closed, and each frame is limited by payload and monotonic-I/O ceilings; this remains frame transport only and does not assemble BiDi messages or grant browser/Agent authority. - Bounded RFC 6455 WebDriver BiDi opening-response validation on the exact peer-verified stream: it admits only HTTP/1.1 `101`, case-insensitive `Upgrade`/`Connection` tokens, and the client-key-correlated `Sec-WebSocket-Accept` value within monotonic time and header-size ceilings; it restores blocking mode and still does not implement WebSocket frames or grant browser/Agent authority. - Bounded WebDriver BiDi loopback TCP transport that consumes one exact no-DNS connect target, retries only explicitly recoverable local transport failures within repository timeout and attempt ceilings, exposes the stream only after operating-system peer inspection and exact peer verification, supports a consuming handoff of the original stream with typed credential-free peer/session/TLS and bounded-attempt evidence, preserves typed causal errors, and performs no DNS, proxy/PAC, process authentication, TLS, WebSocket, BiDi message, browser-action, or Agent-authority step. - Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index d42e321ec..139872e26 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -7,7 +7,7 @@ //! `originweave-core` into one bounded exact TCP connection, binds an RFC 6455 //! opening request to that verified plain stream, and can write that exact request //! under one bounded deadline and validate its bounded RFC 6455 opening response -//! without implementing WebSocket framing or granting browser, WebSocket, TLS, +//! and one bounded frame at a time without granting browser, WebSocket, TLS, //! policy, or Agent authority. #![forbid(unsafe_code)] @@ -26,9 +26,12 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; pub use webdriver_bidi_websocket_handshake::{ + MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_FRAME_TIMEOUT, MAX_WEBSOCKET_OPENING_RESPONSE_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakeError, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrame, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketHandshakeResponseError, - WebDriverBiDiWebSocketOpeningRequestSent, WebDriverBiDiWebSocketOpeningWriteError, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningRequestSent, + WebDriverBiDiWebSocketOpeningWriteError, }; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 9aacf0e02..cc2d23bca 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -16,6 +16,7 @@ use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; const RFC6455_WEBSOCKET_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; const MAX_WEBSOCKET_OPENING_RESPONSE_BYTES: usize = 16 * 1024; +const MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES: usize = 1024 * 1024; /// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. /// @@ -35,6 +36,12 @@ pub const MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT: Duration = Duration::from_secs /// read or interpreted by this boundary. pub const MAX_WEBSOCKET_OPENING_RESPONSE_SIZE: usize = MAX_WEBSOCKET_OPENING_RESPONSE_BYTES; +/// Maximum payload bytes admitted for one WebSocket frame. +pub const MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE: usize = MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES; + +/// Maximum wall-clock budget accepted for one bounded WebSocket frame I/O operation. +pub const MAX_WEBSOCKET_FRAME_TIMEOUT: Duration = Duration::from_secs(5); + fn is_base64_data_byte(byte: u8) -> bool { byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') } @@ -97,6 +104,28 @@ impl WebDriverBiDiWebSocketClientKey { } } +/// Caller-supplied RFC 6455 mask key for one client-to-server frame. +/// +/// RFC 6455 requires every client frame to carry a fresh, unpredictable four-byte key. This type +/// preserves that requirement at the API boundary without inventing an entropy source; callers must +/// obtain a fresh key from an approved randomness source for every frame. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketMaskKey([u8; 4]); + +impl WebDriverBiDiWebSocketMaskKey { + /// Admit one four-byte caller-supplied frame mask key. + #[must_use] + pub const fn new(value: [u8; 4]) -> Self { + Self(value) + } + + /// Borrow the exact four-byte key used on the wire. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 4] { + &self.0 + } +} + /// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. /// /// The plan consumes the verified TCP connection so the opening request cannot be detached from the @@ -383,6 +412,268 @@ impl WebDriverBiDiWebSocketEstablished { pub const fn write_timeout(&self) -> Duration { self.write_timeout } + + /// Write one unfragmented, masked UTF-8 text frame on this verified stream. + /// + /// The operation consumes the established state and returns it only after the complete frame + /// is written and the temporary socket timeout is cleared. The caller must provide a fresh, + /// unpredictable masking key for this frame; it is never exposed in evidence or debug output. + /// This method does not translate JSON, create a BiDi session, or grant browser/Agent authority. + pub fn write_text_frame( + self, + text: &str, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result { + validate_frame_timeout(frame_timeout)?; + if text.len() > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES { + return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: text.len(), + maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES, + }); + } + + let frame = serialize_text_frame(text.as_bytes(), masking_key); + let Self { + mut stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + } = self; + let mut now = Instant::now; + write_frame_with_clock(&mut stream, &frame, frame_timeout, &mut now)?; + Ok(Self { + stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + }) + } + + /// Read one bounded RFC 6455 frame from this verified stream. + /// + /// Server-to-client frames must be unmasked. Data and continuation frames are returned one at + /// a time so a later message layer can enforce fragmentation and JSON semantics; control frames + /// are returned to that layer for protocol handling. Reserved bits/opcodes, oversized payloads, + /// noncanonical lengths, and incomplete reads fail closed. No frame grants browser/Agent + /// authority. + pub fn read_frame( + self, + frame_timeout: Duration, + ) -> Result<(Self, WebDriverBiDiWebSocketFrame), WebDriverBiDiWebSocketFrameError> { + validate_frame_timeout(frame_timeout)?; + let Self { + mut stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + } = self; + let mut now = Instant::now; + let frame = read_frame_with_clock(&mut stream, frame_timeout, &mut now)?; + Ok(( + Self { + stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + }, + frame, + )) + } +} + +/// One validated WebSocket frame received from the established peer. +#[derive(Debug, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketFrame { + fin: bool, + opcode: u8, + payload: Vec, +} + +impl WebDriverBiDiWebSocketFrame { + /// Return whether this is the final frame in its message. + #[must_use] + pub const fn fin(&self) -> bool { + self.fin + } + + /// Return the RFC 6455 opcode without interpreting application semantics. + #[must_use] + pub const fn opcode(&self) -> u8 { + self.opcode + } + + /// Borrow the bounded, unmasked application payload. + #[must_use] + pub fn payload(&self) -> &[u8] { + &self.payload + } +} + +fn validate_frame_timeout(frame_timeout: Duration) -> Result<(), WebDriverBiDiWebSocketFrameError> { + if frame_timeout.is_zero() || frame_timeout > MAX_WEBSOCKET_FRAME_TIMEOUT { + return Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }); + } + Ok(()) +} + +/// Fail-closed errors while reading or writing one bounded WebSocket frame. +#[derive(Debug)] +pub enum WebDriverBiDiWebSocketFrameError { + /// The requested frame I/O deadline was zero or above the reviewed resource ceiling. + InvalidFrameTimeout { + /// Rejected caller-supplied deadline. + frame_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The frame payload exceeded the reviewed memory ceiling. + FrameTooLarge { + /// Rejected payload length in bytes. + payload_bytes: usize, + /// Maximum payload length admitted by this boundary. + maximum_bytes: usize, + }, + /// Applying the operation-local nonblocking read mode failed. + FrameReadModeConfigurationFailed { + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket read timed out before the frame was complete. + FrameReadTimedOut { + /// Number of frame bytes consumed before timeout. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A non-recoverable socket read failed before the frame was complete. + FrameReadFailed { + /// Number of frame bytes consumed before failure. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// The peer ended the stream before the frame was complete. + FrameEnded { + /// Number of frame bytes consumed before EOF. + bytes_read: usize, + }, + /// The frame header violated RFC 6455 or the no-extension policy. + MalformedFrame { + /// Stable, non-secret reason for rejection. + reason: &'static str, + }, + /// Applying the operation-local write timeout failed. + FrameWriteModeConfigurationFailed { + /// Number of frame bytes already written before configuration failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket write timed out before the frame was complete. + FrameWriteTimedOut { + /// Number of frame bytes written before timeout. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A non-recoverable socket write failed before the frame was complete. + FrameWriteFailed { + /// Number of frame bytes written before failure. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// The stream reported zero progress before the frame was complete. + FrameWriteZero { + /// Number of frame bytes written before zero progress. + bytes_written: usize, + }, + /// Clearing the temporary write timeout failed before handoff. + FrameWriteCleanupFailed { + /// Underlying operating-system error. + source: io::Error, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketFrameError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFrameTimeout { .. } => formatter + .write_str("WebDriver BiDi WebSocket frame timeout is outside the reviewed bound"), + Self::FrameTooLarge { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame payload exceeded its bound") + } + Self::FrameReadModeConfigurationFailed { .. } => { + formatter.write_str("failed to configure bounded WebSocket frame reads") + } + Self::FrameReadTimedOut { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame read timed out") + } + Self::FrameReadFailed { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame read failed") + } + Self::FrameEnded { .. } => { + formatter.write_str("WebDriver BiDi WebSocket peer ended the frame stream") + } + Self::MalformedFrame { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame was malformed") + } + Self::FrameWriteModeConfigurationFailed { .. } => { + formatter.write_str("failed to configure bounded WebSocket frame writes") + } + Self::FrameWriteTimedOut { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write timed out") + } + Self::FrameWriteFailed { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write failed") + } + Self::FrameWriteZero { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write made no progress") + } + Self::FrameWriteCleanupFailed { .. } => { + formatter.write_str("failed to clear the WebDriver BiDi WebSocket frame timeout") + } + } + } +} + +impl Error for WebDriverBiDiWebSocketFrameError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::FrameReadModeConfigurationFailed { source } + | Self::FrameReadTimedOut { source, .. } + | Self::FrameReadFailed { source, .. } + | Self::FrameWriteModeConfigurationFailed { source, .. } + | Self::FrameWriteTimedOut { source, .. } + | Self::FrameWriteFailed { source, .. } + | Self::FrameWriteCleanupFailed { source } => Some(source), + Self::InvalidFrameTimeout { .. } + | Self::FrameTooLarge { .. } + | Self::FrameEnded { .. } + | Self::MalformedFrame { .. } + | Self::FrameWriteZero { .. } => None, + } + } } /// Fail-closed errors while reading one bounded WebDriver BiDi WebSocket opening response. @@ -693,6 +984,260 @@ impl OpeningResponseReader for TcpStream { } } +fn serialize_text_frame(payload: &[u8], masking_key: WebDriverBiDiWebSocketMaskKey) -> Vec { + let mut frame = Vec::with_capacity(payload.len() + 14); + frame.push(0x81); + match payload.len() { + 0..=125 => frame.push(0x80 | payload.len() as u8), + 126..=65_535 => { + frame.push(0x80 | 126); + frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + } + length => { + frame.push(0x80 | 127); + frame.extend_from_slice(&(length as u64).to_be_bytes()); + } + } + frame.extend_from_slice(masking_key.as_bytes()); + frame.extend( + payload.iter().enumerate().map(|(index, byte)| { + byte ^ masking_key.as_bytes()[index % masking_key.as_bytes().len()] + }), + ); + frame +} + +trait FrameWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result; +} + +impl FrameWriter for TcpStream { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + TcpStream::set_write_timeout(self, timeout) + } + + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_frame_with_clock( + writer: &mut dyn FrameWriter, + frame: &[u8], + frame_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + frame_timeout; + let mut bytes_written = 0; + while bytes_written < frame.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written, + source: io::Error::new(io::ErrorKind::TimedOut, "frame write deadline elapsed"), + }); + } + writer + .set_write_timeout(Some(remaining)) + .map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written, + source, + } + })?; + match writer.write_frame_bytes(&frame[bytes_written..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written }); + } + Ok(written) => bytes_written += written, + Err(source) => { + if source.kind() == io::ErrorKind::Interrupted { + continue; + } + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) { + if deadline.saturating_duration_since(now()).is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written, + source, + }); + } + thread::sleep(Duration::from_millis(1)); + continue; + } + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written, + source, + }); + } + } + } + writer + .set_write_timeout(None) + .map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { source })?; + Ok(bytes_written) +} + +fn read_frame_with_clock( + reader: &mut dyn OpeningResponseReader, + frame_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + frame_timeout; + reader.set_nonblocking(true).map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { source } + })?; + let mut bytes_read = 0; + let mut header = [0_u8; 2]; + read_frame_bytes_with_clock(reader, &mut header, &mut bytes_read, deadline, now)?; + let first = header[0]; + let second = header[1]; + if first & 0x70 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "reserved frame bits are not negotiated", + }); + } + let fin = first & 0x80 != 0; + let opcode = first & 0x0f; + match opcode { + 0x0..=0x2 => {} + 0x8..=0xa => { + if !fin { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "control frames must not be fragmented", + }); + } + } + _ => { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame opcode is reserved or unsupported", + }); + } + } + if second & 0x80 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "server-to-client frames must not be masked", + }); + } + let length_code = second & 0x7f; + let payload_length = match length_code { + 0..=125 => u64::from(length_code), + 126 => { + let mut extended = [0_u8; 2]; + read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; + let length = u64::from(u16::from_be_bytes(extended)); + if length < 126 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length encoding is not minimal", + }); + } + length + } + _ => { + let mut extended = [0_u8; 8]; + read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; + if extended[0] & 0x80 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length uses the reserved high bit", + }); + } + let length = u64::from_be_bytes(extended); + if length < 65_536 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length encoding is not minimal", + }); + } + length + } + }; + if payload_length > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64 { + return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: payload_length.min(usize::MAX as u64) as usize, + maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES, + }); + } + if opcode >= 0x8 && payload_length > 125 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "control frame payload exceeds 125 bytes", + }); + } + let payload_length = payload_length as usize; + let mut payload = vec![0_u8; payload_length]; + read_frame_bytes_with_clock(reader, &mut payload, &mut bytes_read, deadline, now)?; + reader.set_nonblocking(false).map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameReadFailed { bytes_read, source } + })?; + Ok(WebDriverBiDiWebSocketFrame { + fin, + opcode, + payload, + }) +} + +fn read_frame_bytes_with_clock( + reader: &mut dyn OpeningResponseReader, + destination: &mut [u8], + bytes_read: &mut usize, + deadline: Instant, + now: &mut dyn FnMut() -> Instant, +) -> Result<(), WebDriverBiDiWebSocketFrameError> { + let mut offset = 0; + while offset < destination.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: *bytes_read, + source: io::Error::new(io::ErrorKind::TimedOut, "frame read deadline elapsed"), + }); + } + match reader.read_response_bytes(&mut destination[offset..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameEnded { + bytes_read: *bytes_read, + }); + } + Ok(read) if read > destination.len() - offset => { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: *bytes_read, + source: io::Error::new( + io::ErrorKind::InvalidData, + "frame reader returned more bytes than requested", + ), + }); + } + Ok(read) => { + offset += read; + *bytes_read += read; + } + Err(source) if source.kind() == io::ErrorKind::Interrupted => {} + Err(source) + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + if deadline.saturating_duration_since(now()).is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: *bytes_read, + source, + }); + } + thread::sleep(Duration::from_millis(1)); + } + Err(source) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: *bytes_read, + source, + }); + } + } + } + Ok(()) +} + fn read_opening_response_with_clock( reader: &mut dyn OpeningResponseReader, client_key: &WebDriverBiDiWebSocketClientKey, @@ -978,7 +1523,13 @@ fn write_request_with_clock( #[allow(clippy::expect_used)] mod opening_write_tests { use super::*; - use std::{collections::VecDeque, net::TcpListener, thread}; + use std::{ + collections::VecDeque, + net::{Shutdown, TcpListener}, + thread, + }; + + use originweave_core::WebDriverBiDiWebSocketEndpoint; #[derive(Debug)] enum WriteAction { @@ -1030,6 +1581,21 @@ mod opening_write_tests { } } + impl FrameWriter for FakeWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + let error = if timeout.is_some() { + self.timeout_error + } else { + self.clear_timeout_error + }; + error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) + } + + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write_request_bytes(bytes) + } + } + #[derive(Clone, Debug)] enum ReadAction { Byte(u8), @@ -1109,6 +1675,16 @@ mod opening_write_tests { read_opening_response_with_clock(reader, &key, Duration::from_secs(1), &mut now) } + fn read_frame_with_fake( + reader: &mut FakeReader, + now_values: impl IntoIterator, + ) -> Result { + let fallback = Instant::now(); + let mut now_values = now_values.into_iter(); + let mut now = || now_values.next().unwrap_or(fallback); + read_frame_with_clock(reader, Duration::from_secs(1), &mut now) + } + #[test] fn parser_accepts_case_insensitive_upgrade_tokens_and_rejects_malformed_headers() { let key = client_key(); @@ -1537,4 +2113,363 @@ mod opening_write_tests { assert!(failed.source().is_some()); assert!(cleanup.source().is_some()); } + + #[test] + fn frame_codec_reader_writer_and_errors_are_fully_bounded() { + let masking_key = WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]); + assert_eq!(masking_key.as_bytes(), &[0x37, 0xfa, 0x21, 0x3d]); + for payload in [vec![b'x'; 125], vec![b'x'; 126], vec![b'x'; 65_536]] { + let frame = serialize_text_frame(&payload, masking_key); + assert_eq!(frame[0], 0x81); + assert_ne!(frame[1] & 0x80, 0); + let mask_offset = match payload.len() { + 0..=125 => 2, + 126..=65_535 => 4, + _ => 10, + }; + assert_eq!(&frame[mask_offset..mask_offset + 4], masking_key.as_bytes()); + } + + let start = Instant::now(); + let valid = [0x81, 0x01, b'x']; + let mut valid_reader = FakeReader::new(byte_actions(&valid)); + let valid_frame = read_frame_with_fake(&mut valid_reader, [start]).expect("valid frame"); + assert!(valid_frame.fin()); + assert_eq!(valid_frame.opcode(), 0x1); + assert_eq!(valid_frame.payload(), b"x"); + + let mut ping_reader = FakeReader::new([ReadAction::Byte(0x89), ReadAction::Byte(0)]); + let ping = read_frame_with_fake(&mut ping_reader, [start]).expect("ping frame"); + assert!(ping.fin()); + assert_eq!(ping.opcode(), 0x9); + + let mut continuation_reader = + FakeReader::new([ReadAction::Byte(0x00), ReadAction::Byte(0)]); + let continuation = + read_frame_with_fake(&mut continuation_reader, [start]).expect("continuation frame"); + assert!(!continuation.fin()); + assert_eq!(continuation.opcode(), 0); + + let mut extended_16 = FakeReader::new( + byte_actions(&[0x81, 126, 0, 126]) + .into_iter() + .chain([ReadAction::Count(126)]), + ); + assert_eq!( + read_frame_with_fake(&mut extended_16, [start]) + .expect("extended frame") + .payload() + .len(), + 126 + ); + let mut extended_64 = FakeReader::new( + byte_actions(&[0x81, 127, 0, 0, 0, 0, 0, 1, 0, 0]) + .into_iter() + .chain([ReadAction::Count(65_536)]), + ); + assert_eq!( + read_frame_with_fake(&mut extended_64, [start]) + .expect("large extended frame") + .payload() + .len(), + 65_536 + ); + let mut extended_16_error = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(126), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut extended_16_error, [start]).is_err()); + let mut extended_64_error = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(127), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut extended_64_error, [start]).is_err()); + + let mut oversized_header = vec![0x81, 127]; + oversized_header + .extend_from_slice(&((MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64) + 1).to_be_bytes()); + let mut malformed_readers = vec![ + vec![0xc1, 0], + vec![0x09, 0], + vec![0x83, 0], + vec![0x81, 0x80], + vec![0x81, 126, 0, 1], + vec![0x81, 127, 0x80, 0, 0, 0, 0, 0, 0, 0], + vec![0x81, 127, 0, 0, 0, 0, 0, 0, 0xff, 0xff], + vec![0x89, 126, 0, 126], + oversized_header, + ]; + for bytes in malformed_readers.drain(..) { + let mut reader = FakeReader::new(byte_actions(&bytes)); + assert!(read_frame_with_fake(&mut reader, [start]).is_err()); + } + let mut count_reader = FakeReader::new([ReadAction::Count(3)]); + assert!(read_frame_with_fake(&mut count_reader, [start]).is_err()); + let mut ended_reader = FakeReader::new([ReadAction::Byte(0x81), ReadAction::End]); + assert!(read_frame_with_fake(&mut ended_reader, [start]).is_err()); + let mut interrupted_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) + .chain(byte_actions(&valid)), + ); + assert!(read_frame_with_fake(&mut interrupted_reader, [start]).is_ok()); + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut retrying_reader = FakeReader::new( + std::iter::once(ReadAction::Error(kind)).chain(byte_actions(&valid)), + ); + assert!(read_frame_with_fake(&mut retrying_reader, [start]).is_ok()); + } + let mut payload_error_reader = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(1), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut payload_error_reader, [start]).is_err()); + let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); + assert!(read_frame_with_fake(&mut failed_reader, [start]).is_err()); + let mut mode_reader = FakeReader::new([]); + mode_reader.mode_error = Some(io::ErrorKind::InvalidInput); + assert!(read_frame_with_fake(&mut mode_reader, [start]).is_err()); + let mut timeout_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::WouldBlock)]); + assert!( + read_frame_with_fake( + &mut timeout_reader, + [start, start, start + Duration::from_secs(1)] + ) + .is_err() + ); + let mut deadline_reader = FakeReader::new([]); + assert!( + read_frame_with_fake( + &mut deadline_reader, + [start, start + Duration::from_secs(1)] + ) + .is_err() + ); + let mut cleanup_reader = FakeReader::new(byte_actions(&valid)); + cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); + assert!(read_frame_with_fake(&mut cleanup_reader, [start]).is_err()); + + let mut writer = FakeWriter::new([ + WriteAction::Count(1), + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(99), + ]); + let mut now = || start; + assert_eq!( + write_frame_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now) + .expect("frame write"), + 5 + ); + let mut empty_writer = FakeWriter::new([]); + let mut empty_now = || start; + assert_eq!( + write_frame_with_clock( + &mut empty_writer, + b"", + Duration::from_secs(1), + &mut empty_now + ) + .expect("empty frame write"), + 0 + ); + let mut deadline_writer = FakeWriter::new([]); + let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); + let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); + assert!( + write_frame_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + &mut deadline_now + ) + .is_err() + ); + let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); + let mut zero_now = || start; + assert!( + write_frame_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + &mut zero_now + ) + .is_err() + ); + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut writer = FakeWriter::new([WriteAction::Error(kind)]); + let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); + let mut now = || times.pop_front().unwrap_or(start); + assert!( + write_frame_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now) + .is_err() + ); + } + let mut retrying_writer = FakeWriter::new([ + WriteAction::Error(io::ErrorKind::WouldBlock), + WriteAction::Count(1), + ]); + let mut retrying_now = || start; + assert_eq!( + write_frame_with_clock( + &mut retrying_writer, + b"x", + Duration::from_secs(1), + &mut retrying_now + ) + .expect("retrying frame write"), + 1 + ); + let mut interrupted_writer = FakeWriter::new([ + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(1), + ]); + let mut interrupted_now = || start; + assert_eq!( + write_frame_with_clock( + &mut interrupted_writer, + b"x", + Duration::from_secs(1), + &mut interrupted_now + ) + .expect("interrupted frame write"), + 1 + ); + let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let mut failed_now = || start; + assert!( + write_frame_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + &mut failed_now + ) + .is_err() + ); + let mut configuration_writer = FakeWriter::new([]); + configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let mut configuration_now = || start; + assert!( + write_frame_with_clock( + &mut configuration_writer, + b"x", + Duration::from_secs(1), + &mut configuration_now + ) + .is_err() + ); + let mut cleanup_writer = FakeWriter::new([WriteAction::Count(1)]); + cleanup_writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); + let mut cleanup_now = || start; + assert!( + write_frame_with_clock( + &mut cleanup_writer, + b"x", + Duration::from_secs(1), + &mut cleanup_now + ) + .is_err() + ); + + for timeout in [ + Duration::ZERO, + MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1), + ] { + assert!(validate_frame_timeout(timeout).is_err()); + } + let errors = [ + WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: 2, + maximum_bytes: 1, + }, + WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketFrameError::FrameEnded { bytes_read: 1 }, + WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "test" }, + WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written: 1 }, + WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + ]; + for (error, has_source) in errors.iter().zip([ + false, false, true, true, true, false, false, true, true, true, false, true, + ]) { + assert!(!error.to_string().is_empty()); + assert_eq!(error.source().is_some(), has_source); + } + } + + #[test] + fn established_frame_write_discards_locally_revoked_streams() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("test server must accept"); + stream + .write_all(&valid_response()) + .expect("test server must write response"); + }); + + let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://{address}/session/01234567-89ab-cdef-0123-456789abcdef" + )) + .expect("test endpoint must be valid"); + let correlated = endpoint + .correlate_session_id("01234567-89ab-cdef-0123-456789abcdef") + .expect("test session must correlate"); + let target = correlated + .into_explicit_connect_target() + .expect("test target must be explicit"); + let connection = + crate::WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("test connection plan must be valid") + .connect() + .expect("test connection must succeed"); + let sent = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) + .expect("test handshake plan must be valid") + .write_opening_request(Duration::from_secs(1)) + .expect("test opening request must be written"); + let established = sent + .read_opening_response(Duration::from_secs(1)) + .expect("test opening response must be valid"); + let _ = established.stream.shutdown(Shutdown::Both); + assert!( + established + .write_text_frame( + "x", + WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]), + Duration::from_secs(1), + ) + .is_err() + ); + assert!(server.join().is_ok()); + } } diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs index 9d688cd2f..77081e29c 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs @@ -7,9 +7,11 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ - MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketOpeningWriteError, + MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketOpeningWriteError, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -58,6 +60,24 @@ fn read_opening_request(mut stream: std::net::TcpStream) -> io::Result> Ok(request) } +fn read_client_text_frame(mut stream: std::net::TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + assert_eq!(header[0], 0x81); + assert_ne!(header[1] & 0x80, 0); + let payload_length = usize::from(header[1] & 0x7f); + assert!(payload_length < 126); + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + #[test] fn bounded_opening_write_sends_exact_request_and_preserves_transport_evidence() { let listener = TcpListener::bind(("127.0.0.1", 0)); @@ -326,3 +346,307 @@ fn opening_response_rejects_zero_and_excessive_deadlines_before_socket_mode_chan assert!(server.join().is_ok()); } } + +#[test] +fn established_stream_writes_masked_text_and_reads_unmasked_text_frames() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + read_opening_request(stream.try_clone()?)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let client_payload = read_client_text_frame(stream.try_clone()?)?; + stream.write_all(b"\x89\x00\x81\x08{\"id\":2}")?; + Ok(client_payload) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + let established = written.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + + let established = established.write_text_frame( + r#"{"id":1}"#, + WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]), + Duration::from_millis(500), + ); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + let ping = established.read_frame(Duration::from_millis(500)); + assert!(ping.is_ok(), "{ping:?}"); + let Ok((established, ping)) = ping else { + return; + }; + assert!(ping.fin()); + assert_eq!(ping.opcode(), 0x9); + assert!(ping.payload().is_empty()); + + let received = established.read_frame(Duration::from_millis(500)); + assert!(received.is_ok(), "{received:?}"); + let Ok((_established, frame)) = received else { + return; + }; + assert!(frame.fin()); + assert_eq!(frame.opcode(), 0x1); + assert_eq!(frame.payload(), br#"{"id":2}"#); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(client_payload) = server_result { + assert!(client_payload.is_ok(), "{client_payload:?}"); + if let Ok(client_payload) = client_payload { + assert_eq!(client_payload, br#"{"id":1}"#); + } + } +} + +#[test] +fn established_stream_rejects_oversized_client_text_frames() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(stream.try_clone()?)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + let established = written.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + let payload = "x".repeat(MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE + 1); + let result = established.write_text_frame( + &payload, + WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]), + Duration::from_millis(500), + ); + assert!(matches!( + result, + Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes, + maximum_bytes, + }) if payload_bytes == MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE + 1 + && maximum_bytes == MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE + )); + assert!(server.join().is_ok()); +} + +#[test] +fn established_stream_propagates_frame_write_failures() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + for _ in 0..2 { + let (mut stream, _) = listener.accept()?; + read_opening_request(stream.try_clone()?)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + } + Ok(()) + }); + + for (frame_timeout, invalid_timeout) in + [(Duration::ZERO, true), (Duration::from_nanos(1), false)] + { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + let established = written.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + let result = established.write_text_frame( + "x", + WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]), + frame_timeout, + ); + if invalid_timeout { + assert!(matches!( + result, + Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) + )); + } else { + assert!(matches!( + result, + Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { .. }) + )); + } + } + assert!(server.join().is_ok()); +} + +#[test] +fn established_stream_propagates_frame_read_failures() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(stream.try_clone()?)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.write_all(b"\x81\x01")?; + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + let established = written.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + assert!(established.read_frame(Duration::from_millis(500)).is_err()); + assert!(server.join().is_ok()); +} + +#[test] +fn established_stream_rejects_invalid_read_frame_deadline() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(stream.try_clone()?)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + let established = written.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + assert!(matches!( + established.read_frame(Duration::ZERO), + Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) + )); + assert!(server.join().is_ok()); +} diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index a50323f13..64ac88ef5 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -20,7 +20,7 @@ Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working ## RFC 6455 WebSocket opening handshake -RFC 6455 requires a client opening request to carry a fresh `Sec-WebSocket-Key` and requires a server upgrade response to return HTTP `101`, the `Upgrade: websocket` and `Connection: Upgrade` tokens, and a `Sec-WebSocket-Accept` value derived from that exact client key and the fixed WebSocket GUID. OriginWeave now validates this bounded response on the already peer-verified stream, with duplicate/security-header rejection, a response-size ceiling, and a monotonic deadline. This proves only the RFC 6455 opening exchange; it does not authenticate a browser process, implement WebSocket frames, or grant browser/Agent authority. +RFC 6455 requires a client opening request to carry a fresh `Sec-WebSocket-Key` and requires a server upgrade response to return HTTP `101`, the `Upgrade: websocket` and `Connection: Upgrade` tokens, and a `Sec-WebSocket-Accept` value derived from that exact client key and the fixed WebSocket GUID. OriginWeave now validates this bounded response on the already peer-verified stream, with duplicate/security-header rejection, a response-size ceiling, and a monotonic deadline. The next boundary admits one frame at a time: client text frames require a caller-supplied fresh mask key and are masked on the wire, server frames must be unmasked, reserved bits/opcodes and nonminimal lengths fail closed, control-frame size and payload ceilings are enforced, and I/O remains bounded. Frame transport does not assemble BiDi messages, authenticate a browser process, or grant browser/Agent authority. Primary source: Internet Engineering Task Force, *The WebSocket Protocol* (RFC 6455). From 6a17a9041d8ea0a35d430f33bcd74c0b3ab8ba65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 06:28:10 -0700 Subject: [PATCH 155/570] docs: make PR inventory evidence reproducible --- docs/product-technical-gap-baseline.md | 47 +++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b63c28e09..f77c0a091 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -102,15 +102,52 @@ OriginWeave is not complete merely because every low-level primitive exists in s ## Evidence commands -The volatile counts above were obtained from GitHub search rather than the first 100 results of the pull-request list endpoint: +The volatile counts above are reproducible by paginating the complete open-PR inventory, flattening every page, and then inspecting each PR's exact head, checks, reviews, and review threads: + +```bash +gh api --paginate --slurp 'repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100' \ + > /tmp/originweave-open-pr-pages.json +jq '[.[][]]' /tmp/originweave-open-pr-pages.json \ + > /tmp/originweave-open-prs.json +jq '{ + open_pull_requests: length, + non_draft: (map(select(.draft == false)) | length), + draft: (map(select(.draft == true)) | length) +}' /tmp/originweave-open-prs.json -```text -gh api search/issues -f q='repo:ContextualWisdomLab/OriginWeave is:pr is:open' -gh api search/issues -f q='repo:ContextualWisdomLab/OriginWeave is:pr is:open draft:true' -gh api search/issues -f q='repo:ContextualWisdomLab/OriginWeave is:pr is:open draft:false' gh api repos/ContextualWisdomLab/OriginWeave/branches/main gh api repos/ContextualWisdomLab/OriginWeave/rulesets/18156473 gh api 'repos/ContextualWisdomLab/OriginWeave/collaborators?affiliation=all&per_page=100' + +jq -r '.[].number' /tmp/originweave-open-prs.json | while read -r PR; do + PR_JSON="/tmp/originweave-pr-${PR}.json" + gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" > "$PR_JSON" + HEAD_SHA=$(jq -r '.head.sha' "$PR_JSON") + + gh api --paginate --slurp \ + "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100" \ + > "/tmp/originweave-pr-${PR}-check-runs.json" + gh api --paginate --slurp \ + "repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100" \ + > "/tmp/originweave-pr-${PR}-reviews.json" + gh api graphql --paginate --slurp \ + -F owner=ContextualWisdomLab \ + -F name=OriginWeave \ + -F number="$PR" \ + -f query=' +query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $endCursor) { + nodes { id isResolved isOutdated } + pageInfo { hasNextPage endCursor } + } + } + } +}' > "/tmp/originweave-pr-${PR}-review-threads.json" +done ``` +The ruleset response determines the required workflow names; each PR's exact `HEAD_SHA` then determines which check runs, reviews, and unresolved threads are current. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. + For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. From 9eb3ee1c9e75780ea1488ff440bbcacf46eee017 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:32:05 -0700 Subject: [PATCH 156/570] test(network): accept fail-closed revoked stream timeout setup --- .../tests/webdriver_bidi_websocket_handshake.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index 8717d5a82..e74ae7355 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -117,13 +117,20 @@ fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { }; let write = plan.write_opening_request(Duration::from_secs(1)); - assert!(matches!( - write, + let failed_closed_without_writing = match write { Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { bytes_written: 0, .. - }) - )); + }) => true, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + source, + }, + ) => source.kind() == std::io::ErrorKind::InvalidInput, + _ => false, + }; + assert!(failed_closed_without_writing); let server_result = server.join(); assert!(server_result.is_ok(), "{server_result:?}"); From 5b7a6b63a8b2ac9f262b53b2ed5e1e4077c8bf83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:34:49 -0700 Subject: [PATCH 157/570] test(network): carry revoked-stream fail-closed portability fix --- .../tests/webdriver_bidi_websocket_handshake.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index 8717d5a82..e74ae7355 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -117,13 +117,20 @@ fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { }; let write = plan.write_opening_request(Duration::from_secs(1)); - assert!(matches!( - write, + let failed_closed_without_writing = match write { Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { bytes_written: 0, .. - }) - )); + }) => true, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + source, + }, + ) => source.kind() == std::io::ErrorKind::InvalidInput, + _ => false, + }; + assert!(failed_closed_without_writing); let server_result = server.join(); assert!(server_result.is_ok(), "{server_result:?}"); From 2b02e019eaefc8a6114651268a1b8acd22518953 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:36:22 -0700 Subject: [PATCH 158/570] test(network): carry revoked-stream portability fix into frame stack --- .../tests/webdriver_bidi_websocket_handshake.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index 8717d5a82..e74ae7355 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -117,13 +117,20 @@ fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { }; let write = plan.write_opening_request(Duration::from_secs(1)); - assert!(matches!( - write, + let failed_closed_without_writing = match write { Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { bytes_written: 0, .. - }) - )); + }) => true, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + source, + }, + ) => source.kind() == std::io::ErrorKind::InvalidInput, + _ => false, + }; + assert!(failed_closed_without_writing); let server_result = server.join(); assert!(server_result.is_ok(), "{server_result:?}"); From 1e791f786bc53b96267d709ae72cc10c1cb83108 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:39:42 -0700 Subject: [PATCH 159/570] style(network): apply canonical rustfmt to revoked-stream regression --- .../tests/webdriver_bidi_websocket_handshake.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index e74ae7355..64c1bba6e 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -119,15 +119,12 @@ fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { let write = plan.write_opening_request(Duration::from_secs(1)); let failed_closed_without_writing = match write { Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - .. + bytes_written: 0, .. }) => true, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - source, - }, - ) => source.kind() == std::io::ErrorKind::InvalidInput, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + source, + }) => source.kind() == std::io::ErrorKind::InvalidInput, _ => false, }; assert!(failed_closed_without_writing); From e1c9002869ebc220b807a8cd24b39480f7ff04a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:40:21 -0700 Subject: [PATCH 160/570] style(network): apply canonical rustfmt to inherited revoked-stream regression --- .../tests/webdriver_bidi_websocket_handshake.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index e74ae7355..64c1bba6e 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -119,15 +119,12 @@ fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { let write = plan.write_opening_request(Duration::from_secs(1)); let failed_closed_without_writing = match write { Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - .. + bytes_written: 0, .. }) => true, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - source, - }, - ) => source.kind() == std::io::ErrorKind::InvalidInput, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + source, + }) => source.kind() == std::io::ErrorKind::InvalidInput, _ => false, }; assert!(failed_closed_without_writing); From 84827a8328b30f0c954dc7bef989f66a21aa37c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 07:41:20 -0700 Subject: [PATCH 161/570] style(network): apply canonical rustfmt to inherited revoked-stream regression --- .../tests/webdriver_bidi_websocket_handshake.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index e74ae7355..64c1bba6e 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -119,15 +119,12 @@ fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { let write = plan.write_opening_request(Duration::from_secs(1)); let failed_closed_without_writing = match write { Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - .. + bytes_written: 0, .. }) => true, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - source, - }, - ) => source.kind() == std::io::ErrorKind::InvalidInput, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + source, + }) => source.kind() == std::io::ErrorKind::InvalidInput, _ => false, }; assert!(failed_closed_without_writing); From 154e226019f8795d7a7fa24c48c4baa737583303 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:31:59 -0700 Subject: [PATCH 162/570] test(network): require bounded BiDi locateNodes wire exchange --- ...er_bidi_websocket_locate_nodes_exchange.rs | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs new file mode 100644 index 000000000..a2e0817fd --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -0,0 +1,217 @@ +use std::{ + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; + +fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted explicit target") + }; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + unreachable!("asserted connection plan") + }; + let connection = plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + unreachable!("asserted loopback connection") + }; + connection +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + Ok(request) +} + +fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + assert_eq!(header[0], 0x81); + assert_ne!(header[1] & 0x80, 0); + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture does not admit 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn write_server_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { + let payload_length = u8::try_from(payload.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test response must fit one short WebSocket text frame", + ) + })?; + if payload_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test response must fit one short WebSocket text frame", + )); + } + stream.write_all(&[0x81, payload_length])?; + stream.write_all(payload) +} + +#[test] +fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_result() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + let request = read_opening_request(&mut stream)?; + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client opening request was incomplete", + )); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let command = read_client_text_frame(&mut stream)?; + write_server_text_frame(&mut stream, RESPONSE_DOCUMENT.as_bytes())?; + Ok(command) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + let written = plan.write_opening_request(Duration::from_millis(500)); + assert!(written.is_ok(), "{written:?}"); + let Ok(written) = written else { + return; + }; + let established = written.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; + + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2); + assert!(query.is_ok(), "{query:?}"); + let Ok(query) = query else { + return; + }; + let command = WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query); + assert!(command.is_ok(), "{command:?}"); + let Ok(command) = command else { + return; + }; + let expected_command = command.as_json().as_bytes().to_vec(); + + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + Duration::from_millis(500), + ); + assert!(exchanged.is_ok(), "{exchanged:?}"); + let Ok((established, result)) = exchanged else { + return; + }; + + assert_eq!(result.command_id(), 7); + assert_eq!(result.browsing_context(), "top-level-context"); + assert_eq!(result.max_node_count(), 2); + assert_eq!(result.nodes().len(), 1); + assert_eq!(result.nodes()[0].shared_id(), "shared-1"); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .socket_addr(), + local_addr + ); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .session_id(), + SESSION_ID + ); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(command_result) = server_result { + assert!(command_result.is_ok(), "{command_result:?}"); + if let Ok(actual_command) = command_result { + assert_eq!(actual_command, expected_command); + } + } +} From a178efc4b1b9842cbc1dfe2305e92991f2431c72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:34:44 -0700 Subject: [PATCH 163/570] feat(network): bind locateNodes command to exact WebSocket response --- .../webdriver_bidi_locate_nodes_exchange.rs | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs new file mode 100644 index 000000000..6ed71a8a5 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -0,0 +1,168 @@ +use std::{error::Error, fmt, time::Duration}; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, ValidatedWebDriverBiDiLocateNodesResult, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiResponseDocumentAdmissionError, +}; + +use crate::webdriver_bidi_websocket_handshake::{ + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketMaskKey, +}; + +/// Fail-closed failures while exchanging one bounded WebDriver BiDi `locateNodes` command. +/// +/// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded +/// WebSocket error, raw response bytes must pass the core pre-parser admission contract, and the +/// admitted document must correlate to the exact consumed command before result nodes are returned. +/// An unexpected frame shape has no nested source because it is a protocol-shape refusal rather than +/// an underlying I/O or parser failure. +#[derive(Debug)] +pub enum WebDriverBiDiLocateNodesExchangeError { + /// Bounded WebSocket frame write or read failed. + Frame(WebDriverBiDiWebSocketFrameError), + /// The first returned frame was not one complete text message. + UnexpectedResponseFrame { + /// Whether the returned frame carried the RFC 6455 FIN bit. + fin: bool, + /// Exact returned RFC 6455 opcode. + opcode: u8, + }, + /// The exact response-frame payload failed bounded raw-document admission. + ResponseDocument(WebDriverBiDiResponseDocumentAdmissionError), + /// The admitted response document failed parsing, exact correlation, or node admission. + LocateNodesResponse(WebDriverBiDiLocateNodesResponseDocumentError), +} + +impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Frame(error) => write!( + formatter, + "WebDriver BiDi locateNodes WebSocket frame exchange failed: {error}" + ), + Self::UnexpectedResponseFrame { fin, opcode } => write!( + formatter, + "WebDriver BiDi locateNodes exchange requires one final text response frame; received fin={fin}, opcode=0x{opcode:02x}" + ), + Self::ResponseDocument(error) => write!( + formatter, + "WebDriver BiDi locateNodes response frame failed raw-document admission: {error}" + ), + Self::LocateNodesResponse(error) => write!( + formatter, + "WebDriver BiDi locateNodes response document failed exact wire admission: {error}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesExchangeError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Frame(error) => Some(error), + Self::ResponseDocument(error) => Some(error), + Self::LocateNodesResponse(error) => Some(error), + Self::UnexpectedResponseFrame { .. } => None, + } + } +} + +impl WebDriverBiDiWebSocketEstablished { + /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. + /// + /// The command is serialized by the reviewed core boundary and written as one masked client + /// text frame using the caller-supplied fresh masking key. The first returned server frame must + /// be a complete unmasked text frame (`FIN=1`, opcode `0x1`); continuation, binary, ping, pong, + /// close, and fragmented data fail closed rather than being reinterpreted as a BiDi response. + /// Its exact payload bytes then pass the existing bounded UTF-8/document admission, complete + /// WebDriver BiDi response parser, exact command-id correlation, and wire-derived node admission. + /// + /// `frame_timeout` is independently enforced by the existing bounded write and bounded read + /// operations, so a successful exchange may consume up to two such operation budgets. Any + /// failure consumes this transport state and yields no reusable WebSocket stream, preventing a + /// partially written/read protocol state from being promoted into subsequent authority. + /// + /// Success returns the same exact peer-verified WebSocket stream plus untrusted normalized node + /// evidence. It does not authenticate Chromium/ChromeDriver process provenance, prove current + /// OriginWeave session/context/origin/document authority, authorize policy or typed input, mint + /// node handles, execute a browser action, or prove a post-condition. + pub fn exchange_locate_nodes( + self, + command: WebDriverBiDiLocateNodesCommand, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result< + (Self, ValidatedWebDriverBiDiLocateNodesResult), + WebDriverBiDiLocateNodesExchangeError, + > { + let established = self + .write_text_frame(command.as_json(), masking_key, frame_timeout) + .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + let (established, frame) = established + .read_frame(frame_timeout) + .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + + if !frame.fin() || frame.opcode() != 0x1 { + return Err( + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: frame.fin(), + opcode: frame.opcode(), + }, + ); + } + + let document = BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(frame.payload()) + .map_err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument)?; + let result = command + .admit_response_document_nodes(document) + .map_err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse)?; + Ok((established, result)) + } +} + +#[cfg(test)] +mod tests { + use std::{error::Error as _, time::Duration}; + + use originweave_core::{ + WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiResponseDocumentAdmissionError, + }; + + use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; + + use super::WebDriverBiDiLocateNodesExchangeError; + + #[test] + fn exchange_errors_preserve_typed_sources_and_protocol_shape() { + let frame = WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + ); + assert!(frame.source().is_some()); + assert!(frame.to_string().contains("WebSocket frame exchange failed")); + + let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: false, + opcode: 0x2, + }; + assert!(shape.source().is_none()); + assert!(shape.to_string().contains("fin=false, opcode=0x02")); + + let document = WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8, + ); + assert!(document.source().is_some()); + assert!(document.to_string().contains("raw-document admission")); + + let response = WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + ); + assert!(response.source().is_some()); + assert!(response.to_string().contains("exact wire admission")); + } +} From c757d5fc51edfa49c2177248fcbb50c9ae5b9b36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:35:13 -0700 Subject: [PATCH 164/570] feat(network): expose bounded locateNodes wire exchange --- crates/originweave-network/src/lib.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 139872e26..8e071a0bd 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -6,8 +6,9 @@ //! It also bridges a session-correlated WebDriver BiDi loopback target from //! `originweave-core` into one bounded exact TCP connection, binds an RFC 6455 //! opening request to that verified plain stream, and can write that exact request -//! under one bounded deadline and validate its bounded RFC 6455 opening response -//! and one bounded frame at a time without granting browser, WebSocket, TLS, +//! under one bounded deadline, validate its bounded RFC 6455 opening response, +//! carry one bounded frame at a time, and bind one exact `locateNodes` command to +//! its bounded correlated response without granting browser, WebSocket, TLS, //! policy, or Agent authority. #![forbid(unsafe_code)] @@ -15,6 +16,7 @@ mod connection; mod webdriver_bidi_connection; +mod webdriver_bidi_locate_nodes_exchange; mod webdriver_bidi_websocket_handshake; pub use connection::{ @@ -25,6 +27,7 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; +pub use webdriver_bidi_locate_nodes_exchange::WebDriverBiDiLocateNodesExchangeError; pub use webdriver_bidi_websocket_handshake::{ MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_FRAME_TIMEOUT, MAX_WEBSOCKET_OPENING_RESPONSE_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, From 285d4a5d0bf1524f31d31a0bad9b4bfd7c2f9a9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:36:20 -0700 Subject: [PATCH 165/570] test(network): send canonical locateNodes response JSON From 5ad697bb85b5ecb1c4a459e32f96e03b1319cbcc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:38:30 -0700 Subject: [PATCH 166/570] style(network): apply canonical rustfmt to locateNodes exchange --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 6ed71a8a5..232f57438 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -127,8 +127,7 @@ mod tests { use std::{error::Error as _, time::Duration}; use originweave_core::{ - WebDriverBiDiLocateNodesResponseDocumentError, - WebDriverBiDiResponseDocumentAdmissionError, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, }; use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; @@ -144,7 +143,11 @@ mod tests { }, ); assert!(frame.source().is_some()); - assert!(frame.to_string().contains("WebSocket frame exchange failed")); + assert!( + frame + .to_string() + .contains("WebSocket frame exchange failed") + ); let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { fin: false, From 75a7c45e7e04c03b403668558c56c1eebb77a3c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:11:19 -0700 Subject: [PATCH 167/570] test(core): define BAP task lifecycle contract --- .../tests/bap_task_lifecycle.rs | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 crates/originweave-core/tests/bap_task_lifecycle.rs diff --git a/crates/originweave-core/tests/bap_task_lifecycle.rs b/crates/originweave-core/tests/bap_task_lifecycle.rs new file mode 100644 index 000000000..c5f399731 --- /dev/null +++ b/crates/originweave-core/tests/bap_task_lifecycle.rs @@ -0,0 +1,159 @@ +#![allow(clippy::expect_used)] + +use originweave_core::{ + BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransitionError, +}; + +#[test] +fn bap_task_lifecycle_follows_the_reviewed_resumable_path() { + let mut task = BapTaskLifecycle::new(); + assert_eq!(task.state(), BapTaskState::Created); + assert_eq!(task.transition_sequence(), 0); + + let admitted = task.apply(BapTaskEvent::Admit).expect("admit"); + assert_eq!(admitted.previous_state(), BapTaskState::Created); + assert_eq!(admitted.current_state(), BapTaskState::Admitted); + assert_eq!(admitted.sequence(), 1); + + task.apply(BapTaskEvent::Start).expect("start"); + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait for approval"); + assert_eq!(task.state(), BapTaskState::WaitingForApproval); + + task.apply(BapTaskEvent::Resume).expect("resume approval"); + task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); + assert_eq!(task.state(), BapTaskState::Checkpointed); + + task.apply(BapTaskEvent::Resume).expect("resume checkpoint"); + let succeeded = task.apply(BapTaskEvent::Succeed).expect("succeed"); + assert_eq!(succeeded.current_state(), BapTaskState::Succeeded); + assert!(task.state().is_terminal()); + assert_eq!(task.transition_sequence(), 7); +} + +#[test] +fn waiting_for_external_input_can_resume_but_cannot_succeed_directly() { + let mut task = running_task(); + task.apply(BapTaskEvent::WaitForExternalInput) + .expect("wait for input"); + + let error = task + .apply(BapTaskEvent::Succeed) + .expect_err("waiting task must not skip resume and post-condition work"); + assert_eq!( + error, + BapTaskTransitionError::InvalidTransition { + from: BapTaskState::WaitingForExternalInput, + event: BapTaskEvent::Succeed, + } + ); + assert_eq!(task.state(), BapTaskState::WaitingForExternalInput); + assert_eq!(task.transition_sequence(), 3); + + task.apply(BapTaskEvent::Resume).expect("resume input"); + assert_eq!(task.state(), BapTaskState::Running); +} + +#[test] +fn invalid_transition_is_fail_closed_and_does_not_advance_history() { + let mut task = BapTaskLifecycle::new(); + + let error = task + .apply(BapTaskEvent::Start) + .expect_err("created task must be admitted first"); + assert_eq!( + error, + BapTaskTransitionError::InvalidTransition { + from: BapTaskState::Created, + event: BapTaskEvent::Start, + } + ); + assert_eq!(task.state(), BapTaskState::Created); + assert_eq!(task.transition_sequence(), 0); +} + +#[test] +fn terminal_task_never_reopens_or_advances_history() { + for terminal_event in [ + BapTaskEvent::Succeed, + BapTaskEvent::Fail, + BapTaskEvent::Cancel, + BapTaskEvent::Expire, + ] { + let mut task = if terminal_event == BapTaskEvent::Succeed { + running_task() + } else { + BapTaskLifecycle::new() + }; + task.apply(terminal_event).expect("enter terminal state"); + let terminal_state = task.state(); + let terminal_sequence = task.transition_sequence(); + + for later_event in [ + BapTaskEvent::Admit, + BapTaskEvent::Start, + BapTaskEvent::Resume, + BapTaskEvent::Cancel, + ] { + assert_eq!( + task.apply(later_event), + Err(BapTaskTransitionError::TerminalState { + state: terminal_state, + }) + ); + assert_eq!(task.state(), terminal_state); + assert_eq!(task.transition_sequence(), terminal_sequence); + } + } +} + +#[test] +fn cancellation_and_expiry_cover_pre_dispatch_and_suspended_states() { + for (state, setup) in [ + (BapTaskState::Created, 0_u8), + (BapTaskState::Admitted, 1), + (BapTaskState::Running, 2), + (BapTaskState::WaitingForApproval, 3), + (BapTaskState::WaitingForExternalInput, 4), + (BapTaskState::Checkpointed, 5), + ] { + for terminal_event in [BapTaskEvent::Cancel, BapTaskEvent::Expire] { + let mut task = task_in_state(setup); + assert_eq!(task.state(), state); + task.apply(terminal_event).expect("terminal interruption"); + assert!(task.state().is_terminal()); + } + } +} + +fn running_task() -> BapTaskLifecycle { + let mut task = BapTaskLifecycle::new(); + task.apply(BapTaskEvent::Admit).expect("admit"); + task.apply(BapTaskEvent::Start).expect("start"); + task +} + +fn task_in_state(setup: u8) -> BapTaskLifecycle { + let mut task = BapTaskLifecycle::new(); + if setup >= 1 { + task.apply(BapTaskEvent::Admit).expect("admit"); + } + if setup >= 2 { + task.apply(BapTaskEvent::Start).expect("start"); + } + match setup { + 3 => { + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait approval"); + } + 4 => { + task.apply(BapTaskEvent::WaitForExternalInput) + .expect("wait external"); + } + 5 => { + task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); + } + _ => {} + } + task +} From eeed48c9dd0fe9d3090662d6b411bdb820e04045 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:12:35 -0700 Subject: [PATCH 168/570] test(core): format BAP lifecycle regression --- crates/originweave-core/tests/bap_task_lifecycle.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/bap_task_lifecycle.rs b/crates/originweave-core/tests/bap_task_lifecycle.rs index c5f399731..1026fc619 100644 --- a/crates/originweave-core/tests/bap_task_lifecycle.rs +++ b/crates/originweave-core/tests/bap_task_lifecycle.rs @@ -1,8 +1,6 @@ #![allow(clippy::expect_used)] -use originweave_core::{ - BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransitionError, -}; +use originweave_core::{BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransitionError}; #[test] fn bap_task_lifecycle_follows_the_reviewed_resumable_path() { From 4cea204aff570a3639760e7a1c4a789b33df598d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:16:40 -0700 Subject: [PATCH 169/570] feat(bap): add lifecycle crate manifest --- crates/originweave-bap/Cargo.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 crates/originweave-bap/Cargo.toml diff --git a/crates/originweave-bap/Cargo.toml b/crates/originweave-bap/Cargo.toml new file mode 100644 index 000000000..39e8e38f7 --- /dev/null +++ b/crates/originweave-bap/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "originweave-bap" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[lints] +workspace = true From 681a17bf4f433464022c3bced78783331652b932 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:17:07 -0700 Subject: [PATCH 170/570] feat(bap): implement deterministic task lifecycle kernel --- crates/originweave-bap/src/lib.rs | 197 ++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 crates/originweave-bap/src/lib.rs diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs new file mode 100644 index 000000000..0dc841b3e --- /dev/null +++ b/crates/originweave-bap/src/lib.rs @@ -0,0 +1,197 @@ +//! Stable internal Browser Agent Protocol lifecycle contracts. +//! +//! This crate intentionally owns no transport, browser, network, model, secret, +//! approval, or persistence authority. External protocol adapters may project +//! these states, but protocol metadata cannot mint or change OriginWeave task +//! authority. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +/// Durable logical state of one governed BAP task. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskState { + /// The task record exists but has not entered admission control. + Created, + /// Admission control accepted the task but execution has not started. + Admitted, + /// The task is actively executing governed work. + Running, + /// Execution is suspended until an approval decision is available. + WaitingForApproval, + /// Execution is suspended until required external input is available. + WaitingForExternalInput, + /// Execution is suspended at a compatible recoverable checkpoint. + Checkpointed, + /// The declared post-condition completed successfully. + Succeeded, + /// The task reached a terminal execution failure. + Failed, + /// Cancellation completed and the task cannot resume. + Cancelled, + /// The task exceeded its allowed lifetime and cannot resume. + Expired, +} + +impl BapTaskState { + /// Return whether this state is final and must never transition again. + #[must_use] + pub const fn is_terminal(self) -> bool { + matches!( + self, + Self::Succeeded | Self::Failed | Self::Cancelled | Self::Expired + ) + } +} + +/// One requested task-lifecycle event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskEvent { + /// Admit a newly created task. + Admit, + /// Start an admitted task. + Start, + /// Suspend a running task until approval is available. + WaitForApproval, + /// Suspend a running task until external input is available. + WaitForExternalInput, + /// Suspend a running task at a recoverable checkpoint. + Checkpoint, + /// Resume a suspended task into governed execution. + Resume, + /// Record successful completion after the declared post-condition is verified. + Succeed, + /// Record terminal task failure. + Fail, + /// Record terminal cancellation. + Cancel, + /// Record terminal expiry. + Expire, +} + +/// A fail-closed lifecycle transition failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskTransitionError { + /// The requested event is not valid from the current non-terminal state. + InvalidTransition { + /// Current state that rejected the event. + from: BapTaskState, + /// Event that was rejected. + event: BapTaskEvent, + }, + /// A terminal task cannot be reopened or mutated by lifecycle events. + TerminalState { + /// Final state that rejected all further events. + state: BapTaskState, + }, +} + +/// Immutable receipt for one accepted in-memory lifecycle transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BapTaskTransition { + previous_state: BapTaskState, + current_state: BapTaskState, + sequence: u64, +} + +impl BapTaskTransition { + /// Return the state before the accepted transition. + #[must_use] + pub const fn previous_state(self) -> BapTaskState { + self.previous_state + } + + /// Return the state after the accepted transition. + #[must_use] + pub const fn current_state(self) -> BapTaskState { + self.current_state + } + + /// Return the monotonic transition sequence for this lifecycle instance. + #[must_use] + pub const fn sequence(self) -> u64 { + self.sequence + } +} + +/// Deterministic fail-closed BAP task-lifecycle kernel. +/// +/// This value is intentionally an in-memory state-transition primitive. A +/// durable repository must persist accepted transitions and impose its own +/// bounded sequence/retention contract before commercial task recovery can be +/// claimed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BapTaskLifecycle { + state: BapTaskState, + transition_sequence: u64, +} + +impl BapTaskLifecycle { + /// Create one lifecycle in the `created` state with no accepted transitions. + #[must_use] + pub const fn new() -> Self { + Self { + state: BapTaskState::Created, + transition_sequence: 0, + } + } + + /// Return the current logical task state. + #[must_use] + pub const fn state(self) -> BapTaskState { + self.state + } + + /// Return the number of accepted lifecycle transitions. + #[must_use] + pub const fn transition_sequence(self) -> u64 { + self.transition_sequence + } + + /// Apply one reviewed lifecycle event without granting execution authority. + /// + /// Rejected events leave both state and sequence unchanged. Terminal states + /// reject every later event before evaluating any normal transition rule. + pub fn apply( + &mut self, + event: BapTaskEvent, + ) -> Result { + if self.state.is_terminal() { + return Err(BapTaskTransitionError::TerminalState { state: self.state }); + } + + let next_state = match (self.state, event) { + (BapTaskState::Created, BapTaskEvent::Admit) => BapTaskState::Admitted, + (BapTaskState::Admitted, BapTaskEvent::Start) => BapTaskState::Running, + (BapTaskState::Running, BapTaskEvent::WaitForApproval) => { + BapTaskState::WaitingForApproval + } + (BapTaskState::Running, BapTaskEvent::WaitForExternalInput) => { + BapTaskState::WaitingForExternalInput + } + (BapTaskState::Running, BapTaskEvent::Checkpoint) => BapTaskState::Checkpointed, + ( + BapTaskState::WaitingForApproval + | BapTaskState::WaitingForExternalInput + | BapTaskState::Checkpointed, + BapTaskEvent::Resume, + ) => BapTaskState::Running, + (BapTaskState::Running, BapTaskEvent::Succeed) => BapTaskState::Succeeded, + (_, BapTaskEvent::Fail) => BapTaskState::Failed, + (_, BapTaskEvent::Cancel) => BapTaskState::Cancelled, + (_, BapTaskEvent::Expire) => BapTaskState::Expired, + (from, event) => { + return Err(BapTaskTransitionError::InvalidTransition { from, event }); + } + }; + + let previous_state = self.state; + self.state = next_state; + self.transition_sequence += 1; + Ok(BapTaskTransition { + previous_state, + current_state: next_state, + sequence: self.transition_sequence, + }) + } +} From eca7fc5cc824543541545749bf9c03d24d73b9fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:17:33 -0700 Subject: [PATCH 171/570] test(bap): exercise lifecycle transitions --- .../originweave-bap/tests/task_lifecycle.rs | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 crates/originweave-bap/tests/task_lifecycle.rs diff --git a/crates/originweave-bap/tests/task_lifecycle.rs b/crates/originweave-bap/tests/task_lifecycle.rs new file mode 100644 index 000000000..abc9f00a2 --- /dev/null +++ b/crates/originweave-bap/tests/task_lifecycle.rs @@ -0,0 +1,158 @@ +#![allow(clippy::expect_used)] + +use originweave_bap::{BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransitionError}; + +#[test] +fn bap_task_lifecycle_follows_the_reviewed_resumable_path() { + let mut task = BapTaskLifecycle::new(); + assert_eq!(task.state(), BapTaskState::Created); + assert!(!task.state().is_terminal()); + assert_eq!(task.transition_sequence(), 0); + + let admitted = task.apply(BapTaskEvent::Admit).expect("admit"); + assert_eq!(admitted.previous_state(), BapTaskState::Created); + assert_eq!(admitted.current_state(), BapTaskState::Admitted); + assert_eq!(admitted.sequence(), 1); + + task.apply(BapTaskEvent::Start).expect("start"); + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait for approval"); + assert_eq!(task.state(), BapTaskState::WaitingForApproval); + + task.apply(BapTaskEvent::Resume).expect("resume approval"); + task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); + assert_eq!(task.state(), BapTaskState::Checkpointed); + + task.apply(BapTaskEvent::Resume).expect("resume checkpoint"); + let succeeded = task.apply(BapTaskEvent::Succeed).expect("succeed"); + assert_eq!(succeeded.current_state(), BapTaskState::Succeeded); + assert!(task.state().is_terminal()); + assert_eq!(task.transition_sequence(), 7); +} + +#[test] +fn waiting_for_external_input_can_resume_but_cannot_succeed_directly() { + let mut task = running_task(); + task.apply(BapTaskEvent::WaitForExternalInput) + .expect("wait for input"); + + let error = task + .apply(BapTaskEvent::Succeed) + .expect_err("waiting task must not skip resume and post-condition work"); + assert_eq!( + error, + BapTaskTransitionError::InvalidTransition { + from: BapTaskState::WaitingForExternalInput, + event: BapTaskEvent::Succeed, + } + ); + assert_eq!(task.state(), BapTaskState::WaitingForExternalInput); + assert_eq!(task.transition_sequence(), 3); + + task.apply(BapTaskEvent::Resume).expect("resume input"); + assert_eq!(task.state(), BapTaskState::Running); +} + +#[test] +fn invalid_transition_is_fail_closed_and_does_not_advance_history() { + let mut task = BapTaskLifecycle::new(); + + let error = task + .apply(BapTaskEvent::Start) + .expect_err("created task must be admitted first"); + assert_eq!( + error, + BapTaskTransitionError::InvalidTransition { + from: BapTaskState::Created, + event: BapTaskEvent::Start, + } + ); + assert_eq!(task.state(), BapTaskState::Created); + assert_eq!(task.transition_sequence(), 0); +} + +#[test] +fn terminal_task_never_reopens_or_advances_history() { + for terminal_event in [ + BapTaskEvent::Succeed, + BapTaskEvent::Fail, + BapTaskEvent::Cancel, + BapTaskEvent::Expire, + ] { + let mut task = if terminal_event == BapTaskEvent::Succeed { + running_task() + } else { + BapTaskLifecycle::new() + }; + task.apply(terminal_event).expect("enter terminal state"); + let terminal_state = task.state(); + let terminal_sequence = task.transition_sequence(); + + for later_event in [ + BapTaskEvent::Admit, + BapTaskEvent::Start, + BapTaskEvent::Resume, + BapTaskEvent::Cancel, + ] { + assert_eq!( + task.apply(later_event), + Err(BapTaskTransitionError::TerminalState { + state: terminal_state, + }) + ); + assert_eq!(task.state(), terminal_state); + assert_eq!(task.transition_sequence(), terminal_sequence); + } + } +} + +#[test] +fn cancellation_and_expiry_cover_pre_dispatch_and_suspended_states() { + for (state, setup) in [ + (BapTaskState::Created, 0_u8), + (BapTaskState::Admitted, 1), + (BapTaskState::Running, 2), + (BapTaskState::WaitingForApproval, 3), + (BapTaskState::WaitingForExternalInput, 4), + (BapTaskState::Checkpointed, 5), + ] { + for terminal_event in [BapTaskEvent::Cancel, BapTaskEvent::Expire] { + let mut task = task_in_state(setup); + assert_eq!(task.state(), state); + task.apply(terminal_event).expect("terminal interruption"); + assert!(task.state().is_terminal()); + } + } +} + +fn running_task() -> BapTaskLifecycle { + let mut task = BapTaskLifecycle::new(); + task.apply(BapTaskEvent::Admit).expect("admit"); + task.apply(BapTaskEvent::Start).expect("start"); + task +} + +fn task_in_state(setup: u8) -> BapTaskLifecycle { + let mut task = BapTaskLifecycle::new(); + if setup >= 1 { + task.apply(BapTaskEvent::Admit).expect("admit"); + } + if setup >= 2 { + task.apply(BapTaskEvent::Start).expect("start"); + } + match setup { + 3 => { + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait approval"); + } + 4 => { + task.apply(BapTaskEvent::WaitForExternalInput) + .expect("wait external"); + } + 5 => { + task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); + } + _ => {} + } + task +} From 3dffbfd049b46f278ccc4f77ffa3e24fec16bd92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:17:55 -0700 Subject: [PATCH 172/570] feat(bap): register lifecycle crate --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index fc723f3a4..0d5ab469c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/originweave-core", + "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-resource", "crates/originweave-evidence", From 5864189fe30e03f83a302feb0acb7f7d3a1fc7cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:18:12 -0700 Subject: [PATCH 173/570] test(bap): move lifecycle contract to owning crate --- .../tests/bap_task_lifecycle.rs | 157 ------------------ 1 file changed, 157 deletions(-) delete mode 100644 crates/originweave-core/tests/bap_task_lifecycle.rs diff --git a/crates/originweave-core/tests/bap_task_lifecycle.rs b/crates/originweave-core/tests/bap_task_lifecycle.rs deleted file mode 100644 index 1026fc619..000000000 --- a/crates/originweave-core/tests/bap_task_lifecycle.rs +++ /dev/null @@ -1,157 +0,0 @@ -#![allow(clippy::expect_used)] - -use originweave_core::{BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransitionError}; - -#[test] -fn bap_task_lifecycle_follows_the_reviewed_resumable_path() { - let mut task = BapTaskLifecycle::new(); - assert_eq!(task.state(), BapTaskState::Created); - assert_eq!(task.transition_sequence(), 0); - - let admitted = task.apply(BapTaskEvent::Admit).expect("admit"); - assert_eq!(admitted.previous_state(), BapTaskState::Created); - assert_eq!(admitted.current_state(), BapTaskState::Admitted); - assert_eq!(admitted.sequence(), 1); - - task.apply(BapTaskEvent::Start).expect("start"); - task.apply(BapTaskEvent::WaitForApproval) - .expect("wait for approval"); - assert_eq!(task.state(), BapTaskState::WaitingForApproval); - - task.apply(BapTaskEvent::Resume).expect("resume approval"); - task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); - assert_eq!(task.state(), BapTaskState::Checkpointed); - - task.apply(BapTaskEvent::Resume).expect("resume checkpoint"); - let succeeded = task.apply(BapTaskEvent::Succeed).expect("succeed"); - assert_eq!(succeeded.current_state(), BapTaskState::Succeeded); - assert!(task.state().is_terminal()); - assert_eq!(task.transition_sequence(), 7); -} - -#[test] -fn waiting_for_external_input_can_resume_but_cannot_succeed_directly() { - let mut task = running_task(); - task.apply(BapTaskEvent::WaitForExternalInput) - .expect("wait for input"); - - let error = task - .apply(BapTaskEvent::Succeed) - .expect_err("waiting task must not skip resume and post-condition work"); - assert_eq!( - error, - BapTaskTransitionError::InvalidTransition { - from: BapTaskState::WaitingForExternalInput, - event: BapTaskEvent::Succeed, - } - ); - assert_eq!(task.state(), BapTaskState::WaitingForExternalInput); - assert_eq!(task.transition_sequence(), 3); - - task.apply(BapTaskEvent::Resume).expect("resume input"); - assert_eq!(task.state(), BapTaskState::Running); -} - -#[test] -fn invalid_transition_is_fail_closed_and_does_not_advance_history() { - let mut task = BapTaskLifecycle::new(); - - let error = task - .apply(BapTaskEvent::Start) - .expect_err("created task must be admitted first"); - assert_eq!( - error, - BapTaskTransitionError::InvalidTransition { - from: BapTaskState::Created, - event: BapTaskEvent::Start, - } - ); - assert_eq!(task.state(), BapTaskState::Created); - assert_eq!(task.transition_sequence(), 0); -} - -#[test] -fn terminal_task_never_reopens_or_advances_history() { - for terminal_event in [ - BapTaskEvent::Succeed, - BapTaskEvent::Fail, - BapTaskEvent::Cancel, - BapTaskEvent::Expire, - ] { - let mut task = if terminal_event == BapTaskEvent::Succeed { - running_task() - } else { - BapTaskLifecycle::new() - }; - task.apply(terminal_event).expect("enter terminal state"); - let terminal_state = task.state(); - let terminal_sequence = task.transition_sequence(); - - for later_event in [ - BapTaskEvent::Admit, - BapTaskEvent::Start, - BapTaskEvent::Resume, - BapTaskEvent::Cancel, - ] { - assert_eq!( - task.apply(later_event), - Err(BapTaskTransitionError::TerminalState { - state: terminal_state, - }) - ); - assert_eq!(task.state(), terminal_state); - assert_eq!(task.transition_sequence(), terminal_sequence); - } - } -} - -#[test] -fn cancellation_and_expiry_cover_pre_dispatch_and_suspended_states() { - for (state, setup) in [ - (BapTaskState::Created, 0_u8), - (BapTaskState::Admitted, 1), - (BapTaskState::Running, 2), - (BapTaskState::WaitingForApproval, 3), - (BapTaskState::WaitingForExternalInput, 4), - (BapTaskState::Checkpointed, 5), - ] { - for terminal_event in [BapTaskEvent::Cancel, BapTaskEvent::Expire] { - let mut task = task_in_state(setup); - assert_eq!(task.state(), state); - task.apply(terminal_event).expect("terminal interruption"); - assert!(task.state().is_terminal()); - } - } -} - -fn running_task() -> BapTaskLifecycle { - let mut task = BapTaskLifecycle::new(); - task.apply(BapTaskEvent::Admit).expect("admit"); - task.apply(BapTaskEvent::Start).expect("start"); - task -} - -fn task_in_state(setup: u8) -> BapTaskLifecycle { - let mut task = BapTaskLifecycle::new(); - if setup >= 1 { - task.apply(BapTaskEvent::Admit).expect("admit"); - } - if setup >= 2 { - task.apply(BapTaskEvent::Start).expect("start"); - } - match setup { - 3 => { - task.apply(BapTaskEvent::WaitForApproval) - .expect("wait approval"); - } - 4 => { - task.apply(BapTaskEvent::WaitForExternalInput) - .expect("wait external"); - } - 5 => { - task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); - } - _ => {} - } - task -} From beabc15c0a6d1a33da3128efcf92d5434de5821d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 13:21:24 -0700 Subject: [PATCH 174/570] test(repo): register reusable BAP kernel --- tests/test_repository_contract.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 360e11143..78c636b60 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -20,6 +20,7 @@ def test_workspace_declares_all_independently_reusable_crates(self) -> None: set(data["workspace"]["members"]), { "crates/originweave-core", + "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-destination", "crates/originweave-network", @@ -185,4 +186,4 @@ def test_database_contract_requires_two_word_snake_case(self) -> None: if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From 8da0e4ddaf2e7523e3966388dbcbd6549fca6226 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:49:18 +0900 Subject: [PATCH 175/570] docs: scope baseline delivery status contracts --- CHANGELOG.md | 1 + docs/product-technical-gap-baseline.md | 8 ++++++++ tests/test_product_documentation_contract.py | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e950d19b..476bb20b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. +- Tightened the product-baseline contract so the BiDi opening path and VPN/profile evidence retain their explicit not-shipped status within their own documentation sections. ### Security diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f77c0a091..cc3e509b0 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -30,6 +30,14 @@ Representative active workstreams at this snapshot were: Draft PR #198 is the current top WebDriver BiDi opening-response slice; its prerequisite #195 owns the bounded opening-request write. It remains draft evidence and cannot be treated as shipped behavior. +#### #195/#198 WebDriver BiDi opening path status + +Phase 1 is **in progress**, not shipped. #195 and #198 provide bounded WebSocket opening-path evidence on active branches; framed BiDi commands, authenticated browser-process provenance, semantic task execution, and protected-main integration remain open. + +#### #149 VPN/profile intent status + +It remains draft evidence and cannot be treated as shipped behavior. #149 describes bounded WireGuard/IKEv2 profile authority, but it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof. + The current queue must be processed in dependency order. A green child branch cannot substitute for current checks and review on its prerequisite, synthetic merge, or eventual protected-main commit. PRs that only duplicate, supersede, or preserve stale branch topology should be closed with explicit replacement evidence rather than retained indefinitely. ### Review and merge authority diff --git a/tests/test_product_documentation_contract.py b/tests/test_product_documentation_contract.py index cb4f5a574..fb7c47a05 100644 --- a/tests/test_product_documentation_contract.py +++ b/tests/test_product_documentation_contract.py @@ -11,6 +11,14 @@ class ProductDocumentationContractTests(unittest.TestCase): """Keep product requirements, technical design, diagrams, and traceability discoverable.""" + @staticmethod + def _subsection(text: str, heading: str) -> str: + """Return one fourth-level documentation subsection.""" + start = text.index(heading) + len(heading) + remainder = text[start:] + end = remainder.find("\n#### ") + return remainder if end == -1 else remainder[:end] + def test_authoritative_product_documentation_graph_exists(self) -> None: """Major product decisions must not require reconstructing chat or PR history.""" required_paths = { @@ -57,6 +65,17 @@ def test_product_technical_gap_baseline_records_live_delivery_state(self) -> Non "It remains draft evidence and cannot be treated as shipped behavior.", open_pull_requests, ) + bidi_status = self._subsection( + open_pull_requests, "#### #195/#198 WebDriver BiDi opening path status" + ) + vpn_status = self._subsection( + open_pull_requests, "#### #149 VPN/profile intent status" + ) + self.assertIn("Phase 1 is **in progress**, not shipped.", bidi_status) + self.assertIn( + "It remains draft evidence and cannot be treated as shipped behavior.", + vpn_status, + ) def test_root_architecture_links_the_authoritative_product_graph(self) -> None: """Architecture readers must be able to reach requirements, decisions, diagrams, and data.""" From 606286f51f233ab5fa7237779543f13adf9e4b61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:52:55 +0900 Subject: [PATCH 176/570] build: lock the BAP workspace member --- Cargo.lock | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index e2ada3c4e..1ffe5d1ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,6 +263,10 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "originweave-bap" +version = "0.1.0" + [[package]] name = "originweave-core" version = "0.1.0" From b09f93c65dd143a646ac48cd4fe0d8b7bf3480e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:05:29 +0900 Subject: [PATCH 177/570] test(network): cover locateNodes exchange failures --- ...er_bidi_websocket_locate_nodes_exchange.rs | 172 ++++++++++++++---- 1 file changed, 137 insertions(+), 35 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index a2e0817fd..e9a9aeadf 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -1,6 +1,7 @@ use std::{ + error::Error, io::{self, Read, Write}, - net::{TcpListener, TcpStream}, + net::{SocketAddr, TcpListener, TcpStream}, thread, time::Duration, }; @@ -10,7 +11,8 @@ use originweave_core::{ WebDriverBiDiWebSocketEndpoint, }; use originweave_network::{ - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, }; @@ -18,6 +20,7 @@ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const MISMATCHED_RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":8,"result":{"nodes":[]}}"#; fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); @@ -95,7 +98,7 @@ fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { Ok(payload) } -fn write_server_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> { +fn server_frame(first_byte: u8, payload: &[u8]) -> io::Result> { let payload_length = u8::try_from(payload.len()).map_err(|_| { io::Error::new( io::ErrorKind::InvalidData, @@ -108,23 +111,24 @@ fn write_server_text_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result "test response must fit one short WebSocket text frame", )); } - stream.write_all(&[0x81, payload_length])?; - stream.write_all(payload) + let mut frame = vec![first_byte, payload_length]; + frame.extend_from_slice(payload); + Ok(frame) } -#[test] -fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_result() { - let listener = TcpListener::bind(("127.0.0.1", 0)); - assert!(listener.is_ok(), "{listener:?}"); - let Ok(listener) = listener else { - return; - }; - let local_addr = listener.local_addr(); - assert!(local_addr.is_ok(), "{local_addr:?}"); - let Ok(local_addr) = local_addr else { - return; - }; - +fn establish_with_server_frame( + response_frame: &[u8], +) -> Result< + ( + SocketAddr, + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>>, + ), + Box, +> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let response_frame = response_frame.to_vec(); let server = thread::spawn(move || -> io::Result> { let (mut stream, _) = listener.accept()?; let request = read_opening_request(&mut stream)?; @@ -138,29 +142,58 @@ fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_r b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", )?; let command = read_client_text_frame(&mut stream)?; - write_server_text_frame(&mut stream, RESPONSE_DOCUMENT.as_bytes())?; + stream.write_all(&response_frame)?; Ok(command) }); let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); - let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); - assert!(key.is_ok(), "{key:?}"); - let Ok(key) = key else { - return; - }; - let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key); - assert!(plan.is_ok(), "{plan:?}"); - let Ok(plan) = plan else { - return; - }; - let written = plan.write_opening_request(Duration::from_millis(500)); - assert!(written.is_ok(), "{written:?}"); - let Ok(written) = written else { + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((local_addr, established, server)) +} + +fn locate_nodes_command() -> WebDriverBiDiLocateNodesCommand { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2) + .expect("test query must be valid"); + WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query) + .expect("test command must be valid") +} + +fn exchange_error( + response_frame: &[u8], + frame_timeout: Duration, + server_must_receive_command: bool, +) -> WebDriverBiDiLocateNodesExchangeError { + let (_, established, server) = + establish_with_server_frame(response_frame).expect("test exchange fixture must start"); + let error = established + .exchange_locate_nodes( + locate_nodes_command(), + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + frame_timeout, + ) + .expect_err("test exchange must fail"); + let server_result = server.join().expect("test server must join"); + assert_eq!( + server_result.is_ok(), + server_must_receive_command, + "test server command receipt did not match the exchange boundary" + ); + error +} + +#[test] +fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_result() { + let response_frame = server_frame(0x81, RESPONSE_DOCUMENT.as_bytes()); + assert!(response_frame.is_ok(), "{response_frame:?}"); + let Ok(response_frame) = response_frame else { return; }; - let established = written.read_opening_response(Duration::from_millis(500)); - assert!(established.is_ok(), "{established:?}"); - let Ok(established) = established else { + let fixture = establish_with_server_frame(&response_frame); + assert!(fixture.is_ok(), "{fixture:?}"); + let Ok((local_addr, established, server)) = fixture else { return; }; @@ -215,3 +248,72 @@ fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_r } } } + +#[test] +fn exchange_rejects_a_non_final_or_non_text_response_frame() { + for (first_byte, expected_fin, expected_opcode) in + [(0x01_u8, false, 0x01_u8), (0x82_u8, true, 0x02_u8)] + { + let response_frame = server_frame(first_byte, &[]); + assert!(response_frame.is_ok(), "{response_frame:?}"); + let Ok(response_frame) = response_frame else { + return; + }; + let fixture = establish_with_server_frame(&response_frame); + assert!(fixture.is_ok(), "{fixture:?}"); + let Ok((_, established, server)) = fixture else { + return; + }; + + let error = established + .exchange_locate_nodes( + locate_nodes_command(), + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + Duration::from_millis(500), + ) + .expect_err("invalid response frames must fail closed"); + assert!(matches!( + error, + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin, + opcode, + } if fin == expected_fin && opcode == expected_opcode + )); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(command_result) = server_result { + assert!(command_result.is_ok(), "{command_result:?}"); + } + } +} + +#[test] +fn exchange_preserves_frame_document_and_response_admission_boundaries() { + let write_error = exchange_error(&[], Duration::ZERO, false); + assert!(matches!( + write_error, + WebDriverBiDiLocateNodesExchangeError::Frame(_) + )); + + let read_error = exchange_error(&[], Duration::from_millis(500), true); + assert!(matches!( + read_error, + WebDriverBiDiLocateNodesExchangeError::Frame(_) + )); + + let invalid_utf8_frame = server_frame(0x81, &[0xff]).expect("test frame must be bounded"); + let document_error = exchange_error(&invalid_utf8_frame, Duration::from_millis(500), true); + assert!(matches!( + document_error, + WebDriverBiDiLocateNodesExchangeError::ResponseDocument(_) + )); + + let mismatched_frame = + server_frame(0x81, MISMATCHED_RESPONSE_DOCUMENT.as_bytes()).expect("test frame must fit"); + let response_error = exchange_error(&mismatched_frame, Duration::from_millis(500), true); + assert!(matches!( + response_error, + WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse(_) + )); +} From 28a53fc06bc25d31672db3ea041e218cfeb8d81b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:08:49 +0900 Subject: [PATCH 178/570] fix(bap): satisfy lifecycle default contract --- crates/originweave-bap/src/lib.rs | 6 ++++++ crates/originweave-bap/tests/task_lifecycle.rs | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index 0dc841b3e..a6fdd9d47 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -126,6 +126,12 @@ pub struct BapTaskLifecycle { transition_sequence: u64, } +impl Default for BapTaskLifecycle { + fn default() -> Self { + Self::new() + } +} + impl BapTaskLifecycle { /// Create one lifecycle in the `created` state with no accepted transitions. #[must_use] diff --git a/crates/originweave-bap/tests/task_lifecycle.rs b/crates/originweave-bap/tests/task_lifecycle.rs index abc9f00a2..702be87b2 100644 --- a/crates/originweave-bap/tests/task_lifecycle.rs +++ b/crates/originweave-bap/tests/task_lifecycle.rs @@ -2,6 +2,11 @@ use originweave_bap::{BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransitionError}; +#[test] +fn default_starts_a_new_created_lifecycle() { + assert_eq!(BapTaskLifecycle::default(), BapTaskLifecycle::new()); +} + #[test] fn bap_task_lifecycle_follows_the_reviewed_resumable_path() { let mut task = BapTaskLifecycle::new(); From 735f2499cedb0b56d111f380d7d9f678f0fc606c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:12:30 +0900 Subject: [PATCH 179/570] docs: harden baseline evidence collection --- CHANGELOG.md | 1 + docs/product-technical-gap-baseline.md | 77 ++++++++++++------- tests/test_product_completion_gap_contract.py | 18 ++++- 3 files changed, 65 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 476bb20b0..de47e8cb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Updated the first Chromium slice to distinguish implemented origin, destination, direct TCP, and TLS identity kernels from the remaining trusted DNS adapter, proxy/PAC, HTTP budget, MIME, download, and Chromium integration required before safe navigation can be claimed. - Separated hourly product PR publication authority from the organization review and merge system, and added live default-branch and release-blocker rechecks immediately before publication. - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. +- Hardened the dated baseline evidence collector with fail-fast isolated artifacts, paginated branch and collaborator rules, and post-collection exact-head revalidation. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. - Tightened the product-baseline contract so the BiDi opening path and VPN/profile evidence retain their explicit not-shipped status within their own documentation sections. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index cc3e509b0..da771de77 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -113,36 +113,50 @@ OriginWeave is not complete merely because every low-level primitive exists in s The volatile counts above are reproducible by paginating the complete open-PR inventory, flattening every page, and then inspecting each PR's exact head, checks, reviews, and review threads: ```bash +set -euo pipefail +EVIDENCE_DIR="$(mktemp -d /tmp/originweave-evidence.XXXXXX)" +printf 'Evidence directory: %s\n' "$EVIDENCE_DIR" >&2 + gh api --paginate --slurp 'repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100' \ - > /tmp/originweave-open-pr-pages.json -jq '[.[][]]' /tmp/originweave-open-pr-pages.json \ - > /tmp/originweave-open-prs.json + > "$EVIDENCE_DIR/open-pr-pages.json" +jq '[.[][]]' "$EVIDENCE_DIR/open-pr-pages.json" \ + > "$EVIDENCE_DIR/open-prs.json" jq '{ open_pull_requests: length, non_draft: (map(select(.draft == false)) | length), draft: (map(select(.draft == true)) | length) -}' /tmp/originweave-open-prs.json - -gh api repos/ContextualWisdomLab/OriginWeave/branches/main -gh api repos/ContextualWisdomLab/OriginWeave/rulesets/18156473 -gh api 'repos/ContextualWisdomLab/OriginWeave/collaborators?affiliation=all&per_page=100' - -jq -r '.[].number' /tmp/originweave-open-prs.json | while read -r PR; do - PR_JSON="/tmp/originweave-pr-${PR}.json" - gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" > "$PR_JSON" - HEAD_SHA=$(jq -r '.head.sha' "$PR_JSON") - - gh api --paginate --slurp \ - "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100" \ - > "/tmp/originweave-pr-${PR}-check-runs.json" - gh api --paginate --slurp \ - "repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100" \ - > "/tmp/originweave-pr-${PR}-reviews.json" - gh api graphql --paginate --slurp \ - -F owner=ContextualWisdomLab \ - -F name=OriginWeave \ - -F number="$PR" \ - -f query=' +}' "$EVIDENCE_DIR/open-prs.json" + +gh api 'repos/ContextualWisdomLab/OriginWeave/branches/main' \ + > "$EVIDENCE_DIR/main-branch.json" +gh api --paginate --slurp \ + 'repos/ContextualWisdomLab/OriginWeave/rules/branches/main?per_page=100' \ + > "$EVIDENCE_DIR/main-branch-rule-pages.json" +jq '[.[][]]' "$EVIDENCE_DIR/main-branch-rule-pages.json" \ + > "$EVIDENCE_DIR/main-branch-rules.json" +gh api --paginate --slurp \ + 'repos/ContextualWisdomLab/OriginWeave/collaborators?affiliation=all&per_page=100' \ + > "$EVIDENCE_DIR/collaborator-pages.json" +jq '[.[][]]' "$EVIDENCE_DIR/collaborator-pages.json" \ + > "$EVIDENCE_DIR/collaborators.json" + +jq -r '.[].number' "$EVIDENCE_DIR/open-prs.json" | while read -r PR; do + while :; do + PR_JSON="$EVIDENCE_DIR/pr-${PR}.json" + gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" > "$PR_JSON" + HEAD_SHA=$(jq -r '.head.sha' "$PR_JSON") + + gh api --paginate --slurp \ + "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100" \ + > "$EVIDENCE_DIR/pr-${PR}-check-runs.json" + gh api --paginate --slurp \ + "repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100" \ + > "$EVIDENCE_DIR/pr-${PR}-reviews.json" + gh api graphql --paginate --slurp \ + -F owner=ContextualWisdomLab \ + -F name=OriginWeave \ + -F number="$PR" \ + -f query=' query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { @@ -152,10 +166,19 @@ query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { } } } -}' > "/tmp/originweave-pr-${PR}-review-threads.json" +}' > "$EVIDENCE_DIR/pr-${PR}-review-threads.json" + + RECHECKED_HEAD_SHA=$(gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" \ + | jq -r '.head.sha') + if [[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" ]]; then + break + fi + printf 'Discarding moving-head evidence for PR #%s (%s -> %s) and retrying.\n' \ + "$PR" "$HEAD_SHA" "$RECHECKED_HEAD_SHA" >&2 + done done ``` -The ruleset response determines the required workflow names; each PR's exact `HEAD_SHA` then determines which check runs, reviews, and unresolved threads are current. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. +The branch-scoped rules response determines the active rules affecting `main`; each PR's exact `HEAD_SHA` then determines which check runs, reviews, and unresolved threads are current. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. Evidence is retained only when the post-collection `RECHECKED_HEAD_SHA` equals the collected `HEAD_SHA`. For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 141cb790c..4c69dc3f0 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -47,19 +47,29 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> None: """The evidence procedure must paginate the queue and inspect each exact PR head.""" text = BASELINE.read_text(encoding="utf-8") - evidence = text.split("## Evidence commands", 1)[1] + evidence = text.split("## Evidence commands", 1)[1].split("\n## ", 1)[0] + shell = evidence.split("```bash", 1)[1].split("```", 1)[0] for phrase in ( "--paginate --slurp 'repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100'", - "jq '[.[][]]'", + "set -euo pipefail", + 'EVIDENCE_DIR="$(mktemp -d /tmp/originweave-evidence.XXXXXX)"', + '"$EVIDENCE_DIR/open-pr-pages.json"', + "jq '[.[][]]' \"$EVIDENCE_DIR/open-pr-pages.json\"", '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR"', '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100"', '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100"', "reviewThreads(first: 100, after: $endCursor)", - "rulesets/18156473", + "rules/branches/main?per_page=100", + '"$EVIDENCE_DIR/main-branch-rule-pages.json"', + "RECHECKED_HEAD_SHA=", + '[[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" ]]', ): with self.subTest(phrase=phrase): - self.assertIn(phrase, evidence) + self.assertIn(phrase, shell) + + self.assertIn("while :; do", shell) + self.assertNotIn("/tmp/originweave-open-pr", shell) if __name__ == "__main__": From 91ef8c19fc7a99e05944e519a1cc16da5fdf5362 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:16:27 -0700 Subject: [PATCH 180/570] test(bap): require resumable overflow-safe lifecycle recovery --- .../tests/task_lifecycle_recovery.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 crates/originweave-bap/tests/task_lifecycle_recovery.rs diff --git a/crates/originweave-bap/tests/task_lifecycle_recovery.rs b/crates/originweave-bap/tests/task_lifecycle_recovery.rs new file mode 100644 index 000000000..4138ab3ce --- /dev/null +++ b/crates/originweave-bap/tests/task_lifecycle_recovery.rs @@ -0,0 +1,41 @@ +#![allow(clippy::expect_used)] + +use originweave_bap::{BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransitionError}; + +#[test] +fn restored_lifecycle_preserves_state_and_monotonic_sequence() { + let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, 41); + + assert_eq!(task.state(), BapTaskState::Checkpointed); + assert_eq!(task.transition_sequence(), 41); + + let resumed = task.apply(BapTaskEvent::Resume).expect("resume restored task"); + assert_eq!(resumed.previous_state(), BapTaskState::Checkpointed); + assert_eq!(resumed.current_state(), BapTaskState::Running); + assert_eq!(resumed.sequence(), 42); +} + +#[test] +fn exhausted_sequence_fails_closed_without_mutating_state() { + let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, u64::MAX); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::SequenceExhausted), + ); + assert_eq!(task.state(), BapTaskState::Checkpointed); + assert_eq!(task.transition_sequence(), u64::MAX); +} + +#[test] +fn restored_terminal_lifecycle_remains_terminal() { + let mut task = BapTaskLifecycle::restore(BapTaskState::Succeeded, 9); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::TerminalState { + state: BapTaskState::Succeeded, + }), + ); + assert_eq!(task.transition_sequence(), 9); +} From f73f6cbe086b6d85f3afecd09d46184db42d7a08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:19:35 +0900 Subject: [PATCH 181/570] test(network): satisfy websocket exchange clippy contract --- ...er_bidi_websocket_locate_nodes_exchange.rs | 88 ++++++++++++------- 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index e9a9aeadf..31824d8fc 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -22,6 +22,10 @@ const RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; const MISMATCHED_RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":8,"result":{"nodes":[]}}"#; +type ServerHandle = thread::JoinHandle>>; +type EstablishedFixture = + Result<(SocketAddr, WebDriverBiDiWebSocketEstablished, ServerHandle), Box>; + fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); assert!(admitted.is_ok(), "{admitted:?}"); @@ -116,16 +120,7 @@ fn server_frame(first_byte: u8, payload: &[u8]) -> io::Result> { Ok(frame) } -fn establish_with_server_frame( - response_frame: &[u8], -) -> Result< - ( - SocketAddr, - WebDriverBiDiWebSocketEstablished, - thread::JoinHandle>>, - ), - Box, -> { +fn establish_with_server_frame(response_frame: &[u8]) -> EstablishedFixture { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let response_frame = response_frame.to_vec(); @@ -155,10 +150,17 @@ fn establish_with_server_frame( } fn locate_nodes_command() -> WebDriverBiDiLocateNodesCommand { - let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2) - .expect("test query must be valid"); - WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query) - .expect("test command must be valid") + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2); + assert!(query.is_ok(), "{query:?}"); + let Ok(query) = query else { + unreachable!("asserted valid test query") + }; + let command = WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query); + assert!(command.is_ok(), "{command:?}"); + let Ok(command) = command else { + unreachable!("asserted valid test command") + }; + command } fn exchange_error( @@ -166,16 +168,25 @@ fn exchange_error( frame_timeout: Duration, server_must_receive_command: bool, ) -> WebDriverBiDiLocateNodesExchangeError { - let (_, established, server) = - establish_with_server_frame(response_frame).expect("test exchange fixture must start"); - let error = established - .exchange_locate_nodes( - locate_nodes_command(), - WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - frame_timeout, - ) - .expect_err("test exchange must fail"); - let server_result = server.join().expect("test server must join"); + let fixture = establish_with_server_frame(response_frame); + assert!(fixture.is_ok(), "{fixture:?}"); + let Ok((_, established, server)) = fixture else { + unreachable!("asserted valid test exchange fixture") + }; + let error = established.exchange_locate_nodes( + locate_nodes_command(), + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + frame_timeout, + ); + assert!(error.is_err(), "{error:?}"); + let Err(error) = error else { + unreachable!("asserted failing test exchange") + }; + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + let Ok(server_result) = server_result else { + unreachable!("asserted joined test server") + }; assert_eq!( server_result.is_ok(), server_must_receive_command, @@ -265,13 +276,15 @@ fn exchange_rejects_a_non_final_or_non_text_response_frame() { return; }; - let error = established - .exchange_locate_nodes( - locate_nodes_command(), - WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - Duration::from_millis(500), - ) - .expect_err("invalid response frames must fail closed"); + let error = established.exchange_locate_nodes( + locate_nodes_command(), + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + Duration::from_millis(500), + ); + assert!(error.is_err(), "{error:?}"); + let Err(error) = error else { + unreachable!("asserted invalid response frame failure") + }; assert!(matches!( error, WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { @@ -302,15 +315,22 @@ fn exchange_preserves_frame_document_and_response_admission_boundaries() { WebDriverBiDiLocateNodesExchangeError::Frame(_) )); - let invalid_utf8_frame = server_frame(0x81, &[0xff]).expect("test frame must be bounded"); + let invalid_utf8_frame = server_frame(0x81, &[0xff]); + assert!(invalid_utf8_frame.is_ok(), "{invalid_utf8_frame:?}"); + let Ok(invalid_utf8_frame) = invalid_utf8_frame else { + return; + }; let document_error = exchange_error(&invalid_utf8_frame, Duration::from_millis(500), true); assert!(matches!( document_error, WebDriverBiDiLocateNodesExchangeError::ResponseDocument(_) )); - let mismatched_frame = - server_frame(0x81, MISMATCHED_RESPONSE_DOCUMENT.as_bytes()).expect("test frame must fit"); + let mismatched_frame = server_frame(0x81, MISMATCHED_RESPONSE_DOCUMENT.as_bytes()); + assert!(mismatched_frame.is_ok(), "{mismatched_frame:?}"); + let Ok(mismatched_frame) = mismatched_frame else { + return; + }; let response_error = exchange_error(&mismatched_frame, Duration::from_millis(500), true); assert!(matches!( response_error, From 34b0fbeaffff0f5c01a45197bb2bad63878623d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:22:59 +0900 Subject: [PATCH 182/570] feat(bap): support bounded lifecycle recovery --- CHANGELOG.md | 1 + crates/originweave-bap/src/lib.rs | 18 ++++++++++++++++-- .../tests/task_lifecycle_recovery.rs | 4 +++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d17419927..8dd9bfe1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. +- Resumable BAP lifecycle restoration with monotonic sequence recovery and fail-closed sequence exhaustion. - Authoritative product documentation graph spanning PRD, TRD, ADR lifecycle/index, product-wide UML, conceptual ERD, requirement/decision traceability, threat modeling, product-wide test strategy, operability, API/protocol, release/rollback, and current primary-source standards doctoring, with machine-checkable repository contracts that keep conversation-derived future work distinct from protected-main implementation claims. - Purpose-bound data-governance and privacy baseline that rejects both blanket masking and ambient raw-value propagation, defines field-scoped just-in-time disclosure, opaque-handle/trusted-broker boundaries, model/provider/region policy, retention/deletion/residency/break-glass controls, truthful CSAP/SOC 2 readiness language, and machine-checkable documentation contracts without inventing an OriginWeave-owned production database. - Proposed product-wide target-architecture ADRs for the Rust control plane, isolated execution modes, typed actions, semantic observation/stale-node authority, prompt-injection and secret separation, resource-governor priority, provenance evidence, browser/protocol adapters, crawler policy, and hourly automation operational closure; these remain Proposed rather than shipped claims until protected review and merge. diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index a6fdd9d47..9ce5dc50d 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -79,6 +79,8 @@ pub enum BapTaskTransitionError { /// Event that was rejected. event: BapTaskEvent, }, + /// The lifecycle sequence reached its maximum representable value. + SequenceExhausted, /// A terminal task cannot be reopened or mutated by lifecycle events. TerminalState { /// Final state that rejected all further events. @@ -142,6 +144,15 @@ impl BapTaskLifecycle { } } + /// Restore a lifecycle state and its last accepted transition sequence. + #[must_use] + pub const fn restore(state: BapTaskState, transition_sequence: u64) -> Self { + Self { + state, + transition_sequence, + } + } + /// Return the current logical task state. #[must_use] pub const fn state(self) -> BapTaskState { @@ -191,13 +202,16 @@ impl BapTaskLifecycle { } }; + let Some(sequence) = self.transition_sequence.checked_add(1) else { + return Err(BapTaskTransitionError::SequenceExhausted); + }; let previous_state = self.state; self.state = next_state; - self.transition_sequence += 1; + self.transition_sequence = sequence; Ok(BapTaskTransition { previous_state, current_state: next_state, - sequence: self.transition_sequence, + sequence, }) } } diff --git a/crates/originweave-bap/tests/task_lifecycle_recovery.rs b/crates/originweave-bap/tests/task_lifecycle_recovery.rs index 4138ab3ce..9a193cdfc 100644 --- a/crates/originweave-bap/tests/task_lifecycle_recovery.rs +++ b/crates/originweave-bap/tests/task_lifecycle_recovery.rs @@ -9,7 +9,9 @@ fn restored_lifecycle_preserves_state_and_monotonic_sequence() { assert_eq!(task.state(), BapTaskState::Checkpointed); assert_eq!(task.transition_sequence(), 41); - let resumed = task.apply(BapTaskEvent::Resume).expect("resume restored task"); + let resumed = task + .apply(BapTaskEvent::Resume) + .expect("resume restored task"); assert_eq!(resumed.previous_state(), BapTaskState::Checkpointed); assert_eq!(resumed.current_state(), BapTaskState::Running); assert_eq!(resumed.sequence(), 42); From 1751f3fe783ab8f83df05c165c9a86548fc27a10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:27:19 -0700 Subject: [PATCH 183/570] test(evidence): define schema-bound extraction contract --- .../tests/extraction_schema.rs | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 crates/originweave-evidence/tests/extraction_schema.rs diff --git a/crates/originweave-evidence/tests/extraction_schema.rs b/crates/originweave-evidence/tests/extraction_schema.rs new file mode 100644 index 000000000..2a769f35d --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_schema.rs @@ -0,0 +1,246 @@ +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionSchema, ExtractionSchemaError, + ExtractionSourceChannel, ExtractionValueType, MAX_EXTRACTION_FIELD_COUNT, + MAX_EXTRACTION_IDENTIFIER_BYTES, +}; + +fn field( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + source_channels: &[ExtractionSourceChannel], +) -> ExtractionField { + ExtractionField::new( + identifier, + value_type, + cardinality, + required, + source_channels, + ) + .expect("fixture field must be valid") +} + +#[test] +fn schema_binds_versioned_typed_fields_to_explicit_source_channels() { + let schema = ExtractionSchema::new( + "product-card-v1", + vec![ + field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::StructuredData, + ], + ), + field( + "unit_price", + ExtractionValueType::Decimal, + ExtractionCardinality::ZeroOrOne, + false, + &[ + ExtractionSourceChannel::TableCell, + ExtractionSourceChannel::NetworkResponse, + ], + ), + ], + ) + .expect("schema must be admitted"); + + assert_eq!(schema.version(), "product-card-v1"); + assert_eq!(schema.fields().len(), 2); + assert_eq!(schema.field("product_name").unwrap().identifier(), "product_name"); + assert_eq!( + schema.field("product_name").unwrap().value_type(), + ExtractionValueType::Text + ); + assert_eq!( + schema.field("product_name").unwrap().cardinality(), + ExtractionCardinality::One + ); + assert!(schema.field("product_name").unwrap().required()); + assert_eq!( + schema.field("product_name").unwrap().source_channels(), + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::StructuredData, + ] + ); + assert_eq!( + schema.field("unit_price").unwrap().value_type(), + ExtractionValueType::Decimal + ); + assert_eq!( + schema.field("unit_price").unwrap().cardinality(), + ExtractionCardinality::ZeroOrOne + ); + assert!(!schema.field("unit_price").unwrap().required()); + assert!(schema.field("missing_field").is_none()); +} + +#[test] +fn field_accepts_all_reviewed_value_and_source_channel_variants() { + let cases = [ + (ExtractionValueType::Text, ExtractionSourceChannel::SemanticNode), + (ExtractionValueType::Integer, ExtractionSourceChannel::StructuredData), + (ExtractionValueType::Decimal, ExtractionSourceChannel::TableCell), + (ExtractionValueType::Boolean, ExtractionSourceChannel::NetworkResponse), + ( + ExtractionValueType::Timestamp, + ExtractionSourceChannel::ModelInterpretation, + ), + ]; + + for (index, (value_type, source_channel)) in cases.into_iter().enumerate() { + let field = field( + &format!("field_{index}"), + value_type, + ExtractionCardinality::Many, + false, + &[source_channel], + ); + assert_eq!(field.value_type(), value_type); + assert_eq!(field.cardinality(), ExtractionCardinality::Many); + assert_eq!(field.source_channels(), &[source_channel]); + } +} + +#[test] +fn field_rejects_empty_malformed_or_overlong_identifiers() { + assert_eq!( + ExtractionField::new( + "", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + "Product Name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + "1product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + &"a".repeat(MAX_EXTRACTION_IDENTIFIER_BYTES + 1), + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::LimitExceeded) + ); +} + +#[test] +fn field_requires_a_nonempty_duplicate_free_source_channel_set() { + assert_eq!( + ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[], + ), + Err(ExtractionSchemaError::MissingSourceChannel) + ); + assert_eq!( + ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::SemanticNode, + ], + ), + Err(ExtractionSchemaError::DuplicateSourceChannel) + ); +} + +#[test] +fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overflow() { + assert_eq!( + ExtractionSchema::new("Product Schema", vec![field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )]), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionSchema::new( + &"a".repeat(MAX_EXTRACTION_IDENTIFIER_BYTES + 1), + vec![field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )], + ), + Err(ExtractionSchemaError::LimitExceeded) + ); + assert_eq!( + ExtractionSchema::new("product-card-v1", vec![]), + Err(ExtractionSchemaError::MissingField) + ); + + let duplicate = field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ); + let duplicate_again = field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::StructuredData], + ); + assert_eq!( + ExtractionSchema::new("product-card-v1", vec![duplicate, duplicate_again]), + Err(ExtractionSchemaError::DuplicateField) + ); + + let too_many_fields = (0..=MAX_EXTRACTION_FIELD_COUNT) + .map(|index| { + field( + &format!("field_{index}"), + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::SemanticNode], + ) + }) + .collect(); + assert_eq!( + ExtractionSchema::new("product-card-v1", too_many_fields), + Err(ExtractionSchemaError::LimitExceeded) + ); +} From f088a84967264414893c3908cddf645d01d74056 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:30:04 -0700 Subject: [PATCH 184/570] test(evidence): canonicalize extraction schema regression formatting --- .../tests/extraction_schema.rs | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/originweave-evidence/tests/extraction_schema.rs b/crates/originweave-evidence/tests/extraction_schema.rs index 2a769f35d..ed91045bf 100644 --- a/crates/originweave-evidence/tests/extraction_schema.rs +++ b/crates/originweave-evidence/tests/extraction_schema.rs @@ -52,7 +52,10 @@ fn schema_binds_versioned_typed_fields_to_explicit_source_channels() { assert_eq!(schema.version(), "product-card-v1"); assert_eq!(schema.fields().len(), 2); - assert_eq!(schema.field("product_name").unwrap().identifier(), "product_name"); + assert_eq!( + schema.field("product_name").unwrap().identifier(), + "product_name" + ); assert_eq!( schema.field("product_name").unwrap().value_type(), ExtractionValueType::Text @@ -84,10 +87,22 @@ fn schema_binds_versioned_typed_fields_to_explicit_source_channels() { #[test] fn field_accepts_all_reviewed_value_and_source_channel_variants() { let cases = [ - (ExtractionValueType::Text, ExtractionSourceChannel::SemanticNode), - (ExtractionValueType::Integer, ExtractionSourceChannel::StructuredData), - (ExtractionValueType::Decimal, ExtractionSourceChannel::TableCell), - (ExtractionValueType::Boolean, ExtractionSourceChannel::NetworkResponse), + ( + ExtractionValueType::Text, + ExtractionSourceChannel::SemanticNode, + ), + ( + ExtractionValueType::Integer, + ExtractionSourceChannel::StructuredData, + ), + ( + ExtractionValueType::Decimal, + ExtractionSourceChannel::TableCell, + ), + ( + ExtractionValueType::Boolean, + ExtractionSourceChannel::NetworkResponse, + ), ( ExtractionValueType::Timestamp, ExtractionSourceChannel::ModelInterpretation, @@ -182,13 +197,16 @@ fn field_requires_a_nonempty_duplicate_free_source_channel_set() { #[test] fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overflow() { assert_eq!( - ExtractionSchema::new("Product Schema", vec![field( - "product_name", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - &[ExtractionSourceChannel::SemanticNode], - )]), + ExtractionSchema::new( + "Product Schema", + vec![field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )] + ), Err(ExtractionSchemaError::InvalidIdentifier) ); assert_eq!( From 9bd08ae6157a478b44cfe2dcea0c92758285594d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:34:40 -0700 Subject: [PATCH 185/570] feat(evidence): add bounded extraction schema contracts --- .../src/extraction_schema.rs | 216 ++++++++++++++++++ crates/originweave-evidence/src/lib.rs | 6 + .../tests/extraction_schema.rs | 10 + 3 files changed, 232 insertions(+) create mode 100644 crates/originweave-evidence/src/extraction_schema.rs diff --git a/crates/originweave-evidence/src/extraction_schema.rs b/crates/originweave-evidence/src/extraction_schema.rs new file mode 100644 index 000000000..cbcfde709 --- /dev/null +++ b/crates/originweave-evidence/src/extraction_schema.rs @@ -0,0 +1,216 @@ +//! Versioned schema contracts for typed evidence extraction. +//! +//! These value objects describe what may be extracted and which reviewed +//! evidence channels may support each field. They do not read browser data, +//! disclose protected values, persist artifacts, execute models, or grant any +//! browser, network, secret, approval, or storage authority. + +use std::collections::BTreeSet; + +/// Maximum encoded byte length for an extraction schema or field identifier. +pub const MAX_EXTRACTION_IDENTIFIER_BYTES: usize = 128; +/// Maximum number of fields admitted by one extraction schema. +pub const MAX_EXTRACTION_FIELD_COUNT: usize = 256; + +/// The typed value contract for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionValueType { + /// Bounded textual data. + Text, + /// A whole-number value. + Integer, + /// A decimal numeric value. + Decimal, + /// A boolean value. + Boolean, + /// A timestamp value whose concrete normalization is defined by the schema version. + Timestamp, +} + +/// The number of values admitted for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionCardinality { + /// Exactly one value is admitted. + One, + /// Zero or one value is admitted. + ZeroOrOne, + /// A bounded collection may be admitted by a later extraction runtime. + Many, +} + +/// A reviewed evidence channel that may support an extracted value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionSourceChannel { + /// A semantic browser node with an independently validated identity. + SemanticNode, + /// Embedded structured metadata such as JSON-LD, RDFa, or Microdata. + StructuredData, + /// A bounded table-cell observation. + TableCell, + /// A bounded network response whose origin and response identity are independently verified. + NetworkResponse, + /// A separately approved model interpretation backed by explicit evidence identifiers. + ModelInterpretation, +} + +/// A validation failure while constructing an extraction schema contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtractionSchemaError { + /// A schema or field identifier was empty or outside the accepted identifier grammar. + InvalidIdentifier, + /// An identifier or field collection exceeded its bounded limit. + LimitExceeded, + /// A field did not declare any reviewed source channel. + MissingSourceChannel, + /// A field declared the same source channel more than once. + DuplicateSourceChannel, + /// A schema did not contain any field definitions. + MissingField, + /// A schema declared the same field identifier more than once. + DuplicateField, +} + +/// One typed field declared by a versioned extraction schema. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractionField { + identifier: String, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + source_channels: Vec, +} + +impl ExtractionField { + /// Validate and construct one extraction field contract. + pub fn new( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + source_channels: &[ExtractionSourceChannel], + ) -> Result { + validate_identifier(identifier)?; + if source_channels.is_empty() { + return Err(ExtractionSchemaError::MissingSourceChannel); + } + + let mut seen_channels = BTreeSet::new(); + for source_channel in source_channels { + if !seen_channels.insert(*source_channel) { + return Err(ExtractionSchemaError::DuplicateSourceChannel); + } + } + + Ok(Self { + identifier: identifier.to_owned(), + value_type, + cardinality, + required, + source_channels: source_channels.to_vec(), + }) + } + + /// Return the stable field identifier. + #[must_use] + pub fn identifier(&self) -> &str { + &self.identifier + } + + /// Return the declared value type. + #[must_use] + pub const fn value_type(&self) -> ExtractionValueType { + self.value_type + } + + /// Return the declared cardinality. + #[must_use] + pub const fn cardinality(&self) -> ExtractionCardinality { + self.cardinality + } + + /// Return whether the field must be present in a conforming extraction result. + #[must_use] + pub const fn required(&self) -> bool { + self.required + } + + /// Return the reviewed source channels that may support this field. + #[must_use] + pub fn source_channels(&self) -> &[ExtractionSourceChannel] { + &self.source_channels + } +} + +/// A bounded versioned collection of typed extraction-field contracts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractionSchema { + version: String, + fields: Vec, +} + +impl ExtractionSchema { + /// Validate and construct one versioned extraction schema. + pub fn new( + version: &str, + fields: Vec, + ) -> Result { + validate_identifier(version)?; + if fields.is_empty() { + return Err(ExtractionSchemaError::MissingField); + } + if fields.len() > MAX_EXTRACTION_FIELD_COUNT { + return Err(ExtractionSchemaError::LimitExceeded); + } + + let mut field_identifiers = BTreeSet::new(); + for field in &fields { + if !field_identifiers.insert(field.identifier()) { + return Err(ExtractionSchemaError::DuplicateField); + } + } + + Ok(Self { + version: version.to_owned(), + fields, + }) + } + + /// Return the immutable schema version identifier. + #[must_use] + pub fn version(&self) -> &str { + &self.version + } + + /// Return the schema's ordered field definitions. + #[must_use] + pub fn fields(&self) -> &[ExtractionField] { + &self.fields + } + + /// Find one field by its stable identifier. + #[must_use] + pub fn field(&self, identifier: &str) -> Option<&ExtractionField> { + self.fields + .iter() + .find(|field| field.identifier() == identifier) + } +} + +fn validate_identifier(identifier: &str) -> Result<(), ExtractionSchemaError> { + if identifier.len() > MAX_EXTRACTION_IDENTIFIER_BYTES { + return Err(ExtractionSchemaError::LimitExceeded); + } + + let mut bytes = identifier.bytes(); + let Some(first_byte) = bytes.next() else { + return Err(ExtractionSchemaError::InvalidIdentifier); + }; + if !first_byte.is_ascii_lowercase() { + return Err(ExtractionSchemaError::InvalidIdentifier); + } + if bytes.any(|byte| !matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')) { + return Err(ExtractionSchemaError::InvalidIdentifier); + } + + Ok(()) +} diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index ad183e9eb..c15d4ded8 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -7,8 +7,14 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod extraction_schema; mod sensitive_access; +pub use extraction_schema::{ + ExtractionCardinality, ExtractionField, ExtractionSchema, ExtractionSchemaError, + ExtractionSourceChannel, ExtractionValueType, MAX_EXTRACTION_FIELD_COUNT, + MAX_EXTRACTION_IDENTIFIER_BYTES, +}; pub use sensitive_access::{ MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, SensitiveAccessOutcome, diff --git a/crates/originweave-evidence/tests/extraction_schema.rs b/crates/originweave-evidence/tests/extraction_schema.rs index ed91045bf..2ac696611 100644 --- a/crates/originweave-evidence/tests/extraction_schema.rs +++ b/crates/originweave-evidence/tests/extraction_schema.rs @@ -145,6 +145,16 @@ fn field_rejects_empty_malformed_or_overlong_identifiers() { ), Err(ExtractionSchemaError::InvalidIdentifier) ); + assert_eq!( + ExtractionField::new( + "product name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); assert_eq!( ExtractionField::new( "1product_name", From a76a3bcf316a5e9737e445510553745a1441737e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:36:15 -0700 Subject: [PATCH 186/570] style(evidence): apply canonical rustfmt --- crates/originweave-evidence/src/extraction_schema.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/originweave-evidence/src/extraction_schema.rs b/crates/originweave-evidence/src/extraction_schema.rs index cbcfde709..e4d1b302c 100644 --- a/crates/originweave-evidence/src/extraction_schema.rs +++ b/crates/originweave-evidence/src/extraction_schema.rs @@ -150,10 +150,7 @@ pub struct ExtractionSchema { impl ExtractionSchema { /// Validate and construct one versioned extraction schema. - pub fn new( - version: &str, - fields: Vec, - ) -> Result { + pub fn new(version: &str, fields: Vec) -> Result { validate_identifier(version)?; if fields.is_empty() { return Err(ExtractionSchemaError::MissingField); From 2bf2166ef52b1d0c33e0b5be30f94756599e98f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:38:53 -0700 Subject: [PATCH 187/570] test(evidence): satisfy fail-closed clippy contracts --- .../tests/extraction_schema.rs | 81 +++++++++++-------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/crates/originweave-evidence/tests/extraction_schema.rs b/crates/originweave-evidence/tests/extraction_schema.rs index 2ac696611..07377944b 100644 --- a/crates/originweave-evidence/tests/extraction_schema.rs +++ b/crates/originweave-evidence/tests/extraction_schema.rs @@ -10,7 +10,7 @@ fn field( cardinality: ExtractionCardinality, required: bool, source_channels: &[ExtractionSourceChannel], -) -> ExtractionField { +) -> Result { ExtractionField::new( identifier, value_type, @@ -18,11 +18,11 @@ fn field( required, source_channels, ) - .expect("fixture field must be valid") } #[test] -fn schema_binds_versioned_typed_fields_to_explicit_source_channels() { +fn schema_binds_versioned_typed_fields_to_explicit_source_channels( +) -> Result<(), ExtractionSchemaError> { let schema = ExtractionSchema::new( "product-card-v1", vec![ @@ -35,7 +35,7 @@ fn schema_binds_versioned_typed_fields_to_explicit_source_channels() { ExtractionSourceChannel::SemanticNode, ExtractionSourceChannel::StructuredData, ], - ), + )?, field( "unit_price", ExtractionValueType::Decimal, @@ -45,47 +45,61 @@ fn schema_binds_versioned_typed_fields_to_explicit_source_channels() { ExtractionSourceChannel::TableCell, ExtractionSourceChannel::NetworkResponse, ], - ), + )?, ], - ) - .expect("schema must be admitted"); + )?; assert_eq!(schema.version(), "product-card-v1"); assert_eq!(schema.fields().len(), 2); assert_eq!( - schema.field("product_name").unwrap().identifier(), - "product_name" + schema.field("product_name").map(ExtractionField::identifier), + Some("product_name") ); assert_eq!( - schema.field("product_name").unwrap().value_type(), - ExtractionValueType::Text + schema.field("product_name").map(ExtractionField::value_type), + Some(ExtractionValueType::Text) ); assert_eq!( - schema.field("product_name").unwrap().cardinality(), - ExtractionCardinality::One + schema + .field("product_name") + .map(ExtractionField::cardinality), + Some(ExtractionCardinality::One) ); - assert!(schema.field("product_name").unwrap().required()); assert_eq!( - schema.field("product_name").unwrap().source_channels(), - &[ - ExtractionSourceChannel::SemanticNode, - ExtractionSourceChannel::StructuredData, - ] + schema.field("product_name").map(ExtractionField::required), + Some(true) + ); + let expected_product_sources = [ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::StructuredData, + ]; + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::source_channels), + Some(expected_product_sources.as_slice()) + ); + assert_eq!( + schema.field("unit_price").map(ExtractionField::value_type), + Some(ExtractionValueType::Decimal) ); assert_eq!( - schema.field("unit_price").unwrap().value_type(), - ExtractionValueType::Decimal + schema + .field("unit_price") + .map(ExtractionField::cardinality), + Some(ExtractionCardinality::ZeroOrOne) ); assert_eq!( - schema.field("unit_price").unwrap().cardinality(), - ExtractionCardinality::ZeroOrOne + schema.field("unit_price").map(ExtractionField::required), + Some(false) ); - assert!(!schema.field("unit_price").unwrap().required()); assert!(schema.field("missing_field").is_none()); + Ok(()) } #[test] -fn field_accepts_all_reviewed_value_and_source_channel_variants() { +fn field_accepts_all_reviewed_value_and_source_channel_variants( +) -> Result<(), ExtractionSchemaError> { let cases = [ ( ExtractionValueType::Text, @@ -116,11 +130,12 @@ fn field_accepts_all_reviewed_value_and_source_channel_variants() { ExtractionCardinality::Many, false, &[source_channel], - ); + )?; assert_eq!(field.value_type(), value_type); assert_eq!(field.cardinality(), ExtractionCardinality::Many); assert_eq!(field.source_channels(), &[source_channel]); } + Ok(()) } #[test] @@ -205,7 +220,8 @@ fn field_requires_a_nonempty_duplicate_free_source_channel_set() { } #[test] -fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overflow() { +fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overflow( +) -> Result<(), ExtractionSchemaError> { assert_eq!( ExtractionSchema::new( "Product Schema", @@ -215,7 +231,7 @@ fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overfl ExtractionCardinality::One, true, &[ExtractionSourceChannel::SemanticNode], - )] + )?] ), Err(ExtractionSchemaError::InvalidIdentifier) ); @@ -228,7 +244,7 @@ fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overfl ExtractionCardinality::One, true, &[ExtractionSourceChannel::SemanticNode], - )], + )?], ), Err(ExtractionSchemaError::LimitExceeded) ); @@ -243,14 +259,14 @@ fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overfl ExtractionCardinality::One, true, &[ExtractionSourceChannel::SemanticNode], - ); + )?; let duplicate_again = field( "product_name", ExtractionValueType::Text, ExtractionCardinality::ZeroOrOne, false, &[ExtractionSourceChannel::StructuredData], - ); + )?; assert_eq!( ExtractionSchema::new("product-card-v1", vec![duplicate, duplicate_again]), Err(ExtractionSchemaError::DuplicateField) @@ -266,9 +282,10 @@ fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overfl &[ExtractionSourceChannel::SemanticNode], ) }) - .collect(); + .collect::, _>>()?; assert_eq!( ExtractionSchema::new("product-card-v1", too_many_fields), Err(ExtractionSchemaError::LimitExceeded) ); + Ok(()) } From f7d0c041f357169036c4af3b574da7993d27ee89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:40:17 -0700 Subject: [PATCH 188/570] style(evidence): apply canonical test formatting --- .../tests/extraction_schema.rs | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/originweave-evidence/tests/extraction_schema.rs b/crates/originweave-evidence/tests/extraction_schema.rs index 07377944b..cbb89955e 100644 --- a/crates/originweave-evidence/tests/extraction_schema.rs +++ b/crates/originweave-evidence/tests/extraction_schema.rs @@ -21,8 +21,8 @@ fn field( } #[test] -fn schema_binds_versioned_typed_fields_to_explicit_source_channels( -) -> Result<(), ExtractionSchemaError> { +fn schema_binds_versioned_typed_fields_to_explicit_source_channels() +-> Result<(), ExtractionSchemaError> { let schema = ExtractionSchema::new( "product-card-v1", vec![ @@ -52,11 +52,15 @@ fn schema_binds_versioned_typed_fields_to_explicit_source_channels( assert_eq!(schema.version(), "product-card-v1"); assert_eq!(schema.fields().len(), 2); assert_eq!( - schema.field("product_name").map(ExtractionField::identifier), + schema + .field("product_name") + .map(ExtractionField::identifier), Some("product_name") ); assert_eq!( - schema.field("product_name").map(ExtractionField::value_type), + schema + .field("product_name") + .map(ExtractionField::value_type), Some(ExtractionValueType::Text) ); assert_eq!( @@ -84,9 +88,7 @@ fn schema_binds_versioned_typed_fields_to_explicit_source_channels( Some(ExtractionValueType::Decimal) ); assert_eq!( - schema - .field("unit_price") - .map(ExtractionField::cardinality), + schema.field("unit_price").map(ExtractionField::cardinality), Some(ExtractionCardinality::ZeroOrOne) ); assert_eq!( @@ -98,8 +100,8 @@ fn schema_binds_versioned_typed_fields_to_explicit_source_channels( } #[test] -fn field_accepts_all_reviewed_value_and_source_channel_variants( -) -> Result<(), ExtractionSchemaError> { +fn field_accepts_all_reviewed_value_and_source_channel_variants() +-> Result<(), ExtractionSchemaError> { let cases = [ ( ExtractionValueType::Text, @@ -220,8 +222,8 @@ fn field_requires_a_nonempty_duplicate_free_source_channel_set() { } #[test] -fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overflow( -) -> Result<(), ExtractionSchemaError> { +fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overflow() +-> Result<(), ExtractionSchemaError> { assert_eq!( ExtractionSchema::new( "Product Schema", From 512bc3e050f156550f86c6d06e9b6f5af461dec7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:44:26 -0700 Subject: [PATCH 189/570] docs(changelog): record extraction schema contract --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d17419927..4638b5f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. - Authenticated `originweave-tls` service-identity boundary that consumes an existing verified TCP stream, requires exact TLS-origin and transport-origin equality, derives RFC 9525 DNS or literal-IP reference identity only from the canonical HTTPS origin, validates WebPKI with explicit roots and fixed time, permits only TLS 1.2 and TLS 1.3, and never reconnects or resolves. -- Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior and `NotConfigured` revocation evidence. +- Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior. - Credential-free TLS evidence containing canonical origin, requested and observed peer, DNS/IP reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration. - Credential-free connection and redirect evidence containing canonical addresses, destination classes, target digests, hop numbers, and approved-address counts. - Credential-free verified TCP evidence containing the logical origin, requested socket, observed peer, destination class, successful attempt number, and per-attempt timeout. @@ -24,6 +24,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Real loopback rustls integration covering trusted DNS SAN, Common-Name fallback rejection, wrong-name and untrusted-root rejection, fixed-time expiry and not-yet-valid failures, exact IPv4 and IPv6 SANs, TLS 1.2/TLS 1.3, required and optional ALPN, and transport-origin binding. - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. +- Versioned schema-bound extraction contracts with bounded identifiers and field counts, typed value/cardinality metadata, explicit duplicate-free reviewed source channels, and fail-closed schema validation. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. From d56090a24a34a6a6f369dab9c0774f9aab0fdd7d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:44:59 -0700 Subject: [PATCH 190/570] docs(changelog): preserve revocation evidence wording --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4638b5f62..a67578c1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. - Authenticated `originweave-tls` service-identity boundary that consumes an existing verified TCP stream, requires exact TLS-origin and transport-origin equality, derives RFC 9525 DNS or literal-IP reference identity only from the canonical HTTPS origin, validates WebPKI with explicit roots and fixed time, permits only TLS 1.2 and TLS 1.3, and never reconnects or resolves. -- Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior. +- Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior, and `NotConfigured` revocation evidence. - Credential-free TLS evidence containing canonical origin, requested and observed peer, DNS/IP reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration. - Credential-free connection and redirect evidence containing canonical addresses, destination classes, target digests, hop numbers, and approved-address counts. - Credential-free verified TCP evidence containing the logical origin, requested socket, observed peer, destination class, successful attempt number, and per-attempt timeout. From 8fdb1b9fff63a54a179880f3abcb1421b59837b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 14:45:38 -0700 Subject: [PATCH 191/570] docs(changelog): keep TLS policy text unchanged --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a67578c1b..8a9a59e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. - Authenticated `originweave-tls` service-identity boundary that consumes an existing verified TCP stream, requires exact TLS-origin and transport-origin equality, derives RFC 9525 DNS or literal-IP reference identity only from the canonical HTTPS origin, validates WebPKI with explicit roots and fixed time, permits only TLS 1.2 and TLS 1.3, and never reconnects or resolves. -- Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior, and `NotConfigured` revocation evidence. +- Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior and `NotConfigured` revocation evidence. - Credential-free TLS evidence containing canonical origin, requested and observed peer, DNS/IP reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration. - Credential-free connection and redirect evidence containing canonical addresses, destination classes, target digests, hop numbers, and approved-address counts. - Credential-free verified TCP evidence containing the logical origin, requested socket, observed peer, destination class, successful attempt number, and per-attempt timeout. From 0915eed78ffc7a267ac1770cb125d3b275e3ed7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:13:43 -0700 Subject: [PATCH 192/570] test(bap): reject impossible lifecycle recovery snapshots --- .../tests/task_lifecycle_recovery.rs | 65 +++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/originweave-bap/tests/task_lifecycle_recovery.rs b/crates/originweave-bap/tests/task_lifecycle_recovery.rs index 9a193cdfc..fb32ea06f 100644 --- a/crates/originweave-bap/tests/task_lifecycle_recovery.rs +++ b/crates/originweave-bap/tests/task_lifecycle_recovery.rs @@ -1,10 +1,13 @@ #![allow(clippy::expect_used)] -use originweave_bap::{BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransitionError}; +use originweave_bap::{ + BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, BapTaskState, BapTaskTransitionError, +}; #[test] fn restored_lifecycle_preserves_state_and_monotonic_sequence() { - let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, 41); + let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, 41) + .expect("valid checkpoint snapshot"); assert_eq!(task.state(), BapTaskState::Checkpointed); assert_eq!(task.transition_sequence(), 41); @@ -17,9 +20,62 @@ fn restored_lifecycle_preserves_state_and_monotonic_sequence() { assert_eq!(resumed.sequence(), 42); } +#[test] +fn impossible_restored_snapshots_fail_closed() { + for (state, sequence) in [ + (BapTaskState::Created, 1), + (BapTaskState::Admitted, 0), + (BapTaskState::Admitted, 2), + (BapTaskState::Running, 1), + (BapTaskState::Running, 3), + (BapTaskState::WaitingForApproval, 2), + (BapTaskState::WaitingForApproval, 4), + (BapTaskState::WaitingForExternalInput, 2), + (BapTaskState::WaitingForExternalInput, 4), + (BapTaskState::Checkpointed, 2), + (BapTaskState::Checkpointed, 4), + (BapTaskState::Succeeded, 2), + (BapTaskState::Succeeded, 4), + (BapTaskState::Failed, 0), + (BapTaskState::Cancelled, 0), + (BapTaskState::Expired, 0), + ] { + assert_eq!( + BapTaskLifecycle::restore(state, sequence), + Err(BapTaskRestoreError::InvalidSnapshot { + state, + transition_sequence: sequence, + }), + "state={state:?}, sequence={sequence}", + ); + } +} + +#[test] +fn valid_restored_snapshot_classes_remain_accepted() { + for (state, sequence) in [ + (BapTaskState::Created, 0), + (BapTaskState::Admitted, 1), + (BapTaskState::Running, 2), + (BapTaskState::Running, 4), + (BapTaskState::WaitingForApproval, 3), + (BapTaskState::WaitingForExternalInput, 5), + (BapTaskState::Checkpointed, 7), + (BapTaskState::Succeeded, 3), + (BapTaskState::Failed, 1), + (BapTaskState::Cancelled, 2), + (BapTaskState::Expired, 4), + ] { + let task = BapTaskLifecycle::restore(state, sequence).expect("reachable snapshot"); + assert_eq!(task.state(), state); + assert_eq!(task.transition_sequence(), sequence); + } +} + #[test] fn exhausted_sequence_fails_closed_without_mutating_state() { - let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, u64::MAX); + let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, u64::MAX) + .expect("valid exhausted checkpoint snapshot"); assert_eq!( task.apply(BapTaskEvent::Resume), @@ -31,7 +87,8 @@ fn exhausted_sequence_fails_closed_without_mutating_state() { #[test] fn restored_terminal_lifecycle_remains_terminal() { - let mut task = BapTaskLifecycle::restore(BapTaskState::Succeeded, 9); + let mut task = + BapTaskLifecycle::restore(BapTaskState::Succeeded, 9).expect("valid terminal snapshot"); assert_eq!( task.apply(BapTaskEvent::Resume), From 67fccd5a211b448d85d232ed0d06e40df52b4852 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:18:45 +0900 Subject: [PATCH 193/570] docs: refresh live product gap baseline --- CHANGELOG.md | 1 + docs/product-technical-gap-baseline.md | 9 +++++---- tests/test_product_completion_gap_contract.py | 4 ++-- tests/test_product_documentation_contract.py | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de47e8cb6..845aa66bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. - Tightened the product-baseline contract so the BiDi opening path and VPN/profile evidence retain their explicit not-shipped status within their own documentation sections. +- Refreshed the product and technical gap baseline against the 2026-08-21 live inventory: 149 open pull requests, 111 drafts, and the new hardened-runner/MV3 evidence gap issue #206. ### Security diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index da771de77..31131f06a 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2,7 +2,7 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, architecture decisions, or live GitHub state. It keeps buyer-visible gaps, current issues, active pull-request evidence, and commercial completion tracks in one discoverable place. Protected `main` is the implementation boundary: code in an open pull request is not shipped behavior. -## Observed snapshot: 2026-08-20 +## Observed snapshot: 2026-08-21 ### Protected-main truth @@ -14,7 +14,7 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, a ### Open pull requests -The live repository contained **145 open pull requests: 38 non-draft and 107 draft**. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **149 open pull requests: 38 non-draft and 111 draft**. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. Representative active workstreams at this snapshot were: @@ -61,6 +61,7 @@ This gap does not authorize self-approval, administrative bypass, stale-head mer | #201 | Signed cross-platform Chromium distribution, installer/updater, patch SLA, rollback, SBOM, and provenance | | #202 | Enterprise control and experience plane: operator UI, Keyverse-compatible identity, tenancy, approval, audit, SLO, Figma, and Storybook | | #203 | Release-grade web-agent benchmark and commercial acceptance gate bound to exact signed artifacts | +| #206 | Harden-runner custom detection initialization failure while the MV3 gate remains green | The five newly separated product-completion tracks are **durable WARC/PROV replay**, **stable BAP/MCP runtime API**, **signed cross-platform Chromium distribution**, **enterprise control and experience plane**, and the **commercial acceptance gate**. They are separate issues because each has a distinct authority, data, release, and buyer-acceptance boundary. @@ -79,7 +80,7 @@ The hourly product-development loop is operational infrastructure, not proof tha | P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | | P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | | P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | -| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 145-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 149-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | ## Commercial completion definition @@ -98,7 +99,7 @@ OriginWeave is not complete merely because every low-level primitive exists in s ## Next executable queue -1. Re-fetch all 145 PRs and compute the dependency graph, exact heads/bases, reviews, unresolved threads, current required checks, duplicate/supersession relationships, and branch ancestry. +1. Re-fetch all 149 PRs and compute the dependency graph, exact heads/bases, reviews, unresolved threads, current required checks, duplicate/supersession relationships, and branch ancestry. 2. Integrate merge-ready root PRs first; restack and independently revalidate only the immediate children. Close obsolete alternatives instead of carrying parallel truth. 3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #195/#198 WebSocket opening path and the remaining framed BiDi command/response, semantic observation, policy, action, post-condition, and recovery boundaries. 4. Finish #27 and #10 as separate security tracks; neither should be hidden inside the first browser PR. diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 4c69dc3f0..34c70e9f6 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -17,9 +17,9 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: text = BASELINE.read_text(encoding="utf-8") for phrase in ( - "145 open pull requests", + "149 open pull requests", "38 non-draft", - "107 draft", + "111 draft", "#198", "#199", "#200", diff --git a/tests/test_product_documentation_contract.py b/tests/test_product_documentation_contract.py index fb7c47a05..211223c8f 100644 --- a/tests/test_product_documentation_contract.py +++ b/tests/test_product_documentation_contract.py @@ -44,7 +44,7 @@ def test_product_technical_gap_baseline_records_live_delivery_state(self) -> Non self.assertTrue(baseline.is_file()) text = baseline.read_text(encoding="utf-8") for phrase in ( - "Observed snapshot: 2026-08-20", + "Observed snapshot: 2026-08-21", "Protected-main truth", "Open pull requests", "Open issues", From 2c721f907c250e0003979b7b25543749b35339e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:22:51 +0900 Subject: [PATCH 194/570] docs: record current merge authority --- CHANGELOG.md | 1 + docs/product-technical-gap-baseline.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 845aa66bd..3ff81084e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. - Tightened the product-baseline contract so the BiDi opening path and VPN/profile evidence retain their explicit not-shipped status within their own documentation sections. - Refreshed the product and technical gap baseline against the 2026-08-21 live inventory: 149 open pull requests, 111 drafts, and the new hardened-runner/MV3 evidence gap issue #206. +- Refreshed the baseline's merge-authority statement to the live ruleset: two approving reviews are required, while the collaborator inventory still contains only the solo maintainer. ### Security diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 31131f06a..794ef601c 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -42,7 +42,7 @@ The current queue must be processed in dependency order. A green child branch ca ### Review and merge authority -The active `CWL Central required workflows` ruleset requires one approving review, approval after the last push, resolved review threads, and configured required workflows. The previously observed collaborator inventory contained only `seonghobae` with administration and push permissions, creating a **reviewer-provisioning gap** for counted non-author approval. +The active `CWL Central required workflows` ruleset requires two approving reviews, approval after the last push, resolved review threads, and configured required workflows. The current collaborator inventory contains only `seonghobae` with administration and push permissions, creating a **reviewer-provisioning gap** for counted non-author approval. This gap does not authorize self-approval, administrative bypass, stale-head merge, or weaker checks. Exact current-head checks, security gates, complete coverage, rustdoc/Clippy, thread resolution, and branch protection remain mandatory. Before any merge decision, re-fetch the exact ruleset, collaborators, PR head/base, reviews, unresolved threads, and required checks; do not assume this dated observation remains current. From 4be8376d83919fbffd79daec53c0301252bc94e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:29:23 -0700 Subject: [PATCH 195/570] fix(bap): validate recovered lifecycle snapshots --- crates/originweave-bap/src/lib.rs | 51 ++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index 9ce5dc50d..37e2432c2 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -88,6 +88,18 @@ pub enum BapTaskTransitionError { }, } +/// A fail-closed lifecycle recovery failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskRestoreError { + /// The supplied state and transition sequence cannot arise from this state machine. + InvalidSnapshot { + /// Logical state supplied by the durable recovery boundary. + state: BapTaskState, + /// Last accepted transition sequence supplied by the durable recovery boundary. + transition_sequence: u64, + }, +} + /// Immutable receipt for one accepted in-memory lifecycle transition. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BapTaskTransition { @@ -145,12 +157,24 @@ impl BapTaskLifecycle { } /// Restore a lifecycle state and its last accepted transition sequence. - #[must_use] - pub const fn restore(state: BapTaskState, transition_sequence: u64) -> Self { - Self { + /// + /// Recovery accepts only state/sequence pairs that are reachable through + /// this exact state machine. This prevents corrupt or stale durable metadata + /// from manufacturing an impossible execution state. + pub const fn restore( + state: BapTaskState, + transition_sequence: u64, + ) -> Result { + if !reachable_snapshot(state, transition_sequence) { + return Err(BapTaskRestoreError::InvalidSnapshot { + state, + transition_sequence, + }); + } + Ok(Self { state, transition_sequence, - } + }) } /// Return the current logical task state. @@ -215,3 +239,22 @@ impl BapTaskLifecycle { }) } } + +const fn reachable_snapshot(state: BapTaskState, transition_sequence: u64) -> bool { + match state { + BapTaskState::Created => transition_sequence == 0, + BapTaskState::Admitted => transition_sequence == 1, + BapTaskState::Running => transition_sequence >= 2 && transition_sequence.is_multiple_of(2), + BapTaskState::WaitingForApproval + | BapTaskState::WaitingForExternalInput + | BapTaskState::Checkpointed => { + transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) + } + BapTaskState::Succeeded => { + transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) + } + BapTaskState::Failed | BapTaskState::Cancelled | BapTaskState::Expired => { + transition_sequence >= 1 + } + } +} From 4e6d334ac4deb23d789a875e6545a5f26bbad30b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:30:15 -0700 Subject: [PATCH 196/570] test(docs): require exact-head merge verdict evidence --- tests/test_product_completion_gap_contract.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 34c70e9f6..f0c80b58d 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -58,10 +58,17 @@ def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> "jq '[.[][]]' \"$EVIDENCE_DIR/open-pr-pages.json\"", '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR"', '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100"', + '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/statuses?per_page=100"', '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100"', "reviewThreads(first: 100, after: $endCursor)", "rules/branches/main?per_page=100", '"$EVIDENCE_DIR/main-branch-rule-pages.json"', + '.state == "APPROVED"', + ".submitted_at != null", + ".commit_id == $head", + "required_workflows", + "required_status_checks", + '"$EVIDENCE_DIR/pr-${PR}-merge-verdict.json"', "RECHECKED_HEAD_SHA=", '[[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" ]]', ): From b20a8b71c946b4317c59893d78e8ee935d677b17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:32:45 -0700 Subject: [PATCH 197/570] test(docs): align merge evidence contract with rules API --- tests/test_product_completion_gap_contract.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index f0c80b58d..8be8715ef 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -66,7 +66,8 @@ def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> '.state == "APPROVED"', ".submitted_at != null", ".commit_id == $head", - "required_workflows", + '.type == "workflows"', + ".parameters.workflows", "required_status_checks", '"$EVIDENCE_DIR/pr-${PR}-merge-verdict.json"', "RECHECKED_HEAD_SHA=", From 3f6c057a04ffc462759ed5d231da5e1307ad250a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 15:33:37 -0700 Subject: [PATCH 198/570] test(docs): bound merge evidence retries --- tests/test_product_completion_gap_contract.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 8be8715ef..dee74a62a 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -60,6 +60,7 @@ def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100"', '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/statuses?per_page=100"', '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100"', + '"repos/ContextualWisdomLab/OriginWeave/actions/runs?head_sha=$HEAD_SHA&per_page=100"', "reviewThreads(first: 100, after: $endCursor)", "rules/branches/main?per_page=100", '"$EVIDENCE_DIR/main-branch-rule-pages.json"', @@ -70,13 +71,14 @@ def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> ".parameters.workflows", "required_status_checks", '"$EVIDENCE_DIR/pr-${PR}-merge-verdict.json"', + "for ATTEMPT in 1 2 3; do", "RECHECKED_HEAD_SHA=", '[[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" ]]', ): with self.subTest(phrase=phrase): self.assertIn(phrase, shell) - self.assertIn("while :; do", shell) + self.assertNotIn("while :; do", shell) self.assertNotIn("/tmp/originweave-open-pr", shell) From a29b02995dd1089f487aedafb349ef7b195dd7fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:50:23 +0900 Subject: [PATCH 199/570] test(docs): harden exact-head evidence collection --- CHANGELOG.md | 1 + docs/product-technical-gap-baseline.md | 50 ++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff81084e..2a10bc3f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Separated hourly product PR publication authority from the organization review and merge system, and added live default-branch and release-blocker rechecks immediately before publication. - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. - Hardened the dated baseline evidence collector with fail-fast isolated artifacts, paginated branch and collaborator rules, and post-collection exact-head revalidation. +- Hardened the baseline evidence procedure with exact-head legacy status and workflow-run capture, counted approval binding, required-workflow recording, merge verdict artifacts, and bounded moving-head retries. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. - Tightened the product-baseline contract so the BiDi opening path and VPN/profile evidence retain their explicit not-shipped status within their own documentation sections. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 794ef601c..c0515584c 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -142,7 +142,8 @@ jq '[.[][]]' "$EVIDENCE_DIR/collaborator-pages.json" \ > "$EVIDENCE_DIR/collaborators.json" jq -r '.[].number' "$EVIDENCE_DIR/open-prs.json" | while read -r PR; do - while :; do + STABLE_HEAD=false + for ATTEMPT in 1 2 3; do PR_JSON="$EVIDENCE_DIR/pr-${PR}.json" gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" > "$PR_JSON" HEAD_SHA=$(jq -r '.head.sha' "$PR_JSON") @@ -150,9 +151,15 @@ jq -r '.[].number' "$EVIDENCE_DIR/open-prs.json" | while read -r PR; do gh api --paginate --slurp \ "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100" \ > "$EVIDENCE_DIR/pr-${PR}-check-runs.json" + gh api --paginate --slurp \ + "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/statuses?per_page=100" \ + > "$EVIDENCE_DIR/pr-${PR}-statuses.json" gh api --paginate --slurp \ "repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100" \ > "$EVIDENCE_DIR/pr-${PR}-reviews.json" + gh api --paginate --slurp \ + "repos/ContextualWisdomLab/OriginWeave/actions/runs?head_sha=$HEAD_SHA&per_page=100" \ + > "$EVIDENCE_DIR/pr-${PR}-workflow-runs.json" gh api graphql --paginate --slurp \ -F owner=ContextualWisdomLab \ -F name=OriginWeave \ @@ -169,17 +176,56 @@ query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { } }' > "$EVIDENCE_DIR/pr-${PR}-review-threads.json" + jq -n \ + --arg head "$HEAD_SHA" \ + --slurpfile pr "$PR_JSON" \ + --slurpfile checks "$EVIDENCE_DIR/pr-${PR}-check-runs.json" \ + --slurpfile statuses "$EVIDENCE_DIR/pr-${PR}-statuses.json" \ + --slurpfile reviews "$EVIDENCE_DIR/pr-${PR}-reviews.json" \ + --slurpfile workflow_runs "$EVIDENCE_DIR/pr-${PR}-workflow-runs.json" \ + --slurpfile rules "$EVIDENCE_DIR/main-branch-rules.json" \ + --slurpfile threads "$EVIDENCE_DIR/pr-${PR}-review-threads.json" \ + '{ + head_sha: $head, + base_sha: $pr[0].base.sha, + required_status_checks: { + check_runs: [$checks[][]?], + legacy_statuses: [$statuses[][]?] + }, + workflow_runs: [$workflow_runs[0].workflow_runs[]?], + counted_approvals: [ + $reviews[][]? + | select(.state == "APPROVED") + | select(.submitted_at != null) + | select(.commit_id == $head) + ], + required_workflows: [ + $rules[][]? + | select(.type == "workflows") + | .parameters.workflows[] + ], + unresolved_threads: [ + $threads[]?.data.repository.pullRequest.reviewThreads.nodes[]? + | select(.isResolved == false and .isOutdated == false) + ] + }' > "$EVIDENCE_DIR/pr-${PR}-merge-verdict.json" + RECHECKED_HEAD_SHA=$(gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" \ | jq -r '.head.sha') if [[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" ]]; then + STABLE_HEAD=true break fi printf 'Discarding moving-head evidence for PR #%s (%s -> %s) and retrying.\n' \ "$PR" "$HEAD_SHA" "$RECHECKED_HEAD_SHA" >&2 done + if [[ "$STABLE_HEAD" != true ]]; then + printf 'Unable to collect stable exact-head evidence for PR #%s after 3 attempts.\n' "$PR" >&2 + exit 1 + fi done ``` -The branch-scoped rules response determines the active rules affecting `main`; each PR's exact `HEAD_SHA` then determines which check runs, reviews, and unresolved threads are current. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. Evidence is retained only when the post-collection `RECHECKED_HEAD_SHA` equals the collected `HEAD_SHA`. +The branch-scoped rules response determines the active rules affecting `main`; each PR's exact `HEAD_SHA` then determines which check runs, legacy statuses, workflow runs, reviews, and unresolved threads are current. The saved merge verdict binds counted approvals to `APPROVED`, non-null submission times, and the exact head, while preserving required workflow rules. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. Evidence is retained only when the post-collection `RECHECKED_HEAD_SHA` equals the collected `HEAD_SHA`; a moving head fails after three bounded attempts. For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. From 6737ae13f9387598737a37e3b23d8593b8723274 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:51:07 +0900 Subject: [PATCH 200/570] fix(docs): preserve exact check-run records --- docs/product-technical-gap-baseline.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c0515584c..9263c9e63 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -189,16 +189,16 @@ query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { head_sha: $head, base_sha: $pr[0].base.sha, required_status_checks: { - check_runs: [$checks[][]?], + check_runs: [$checks[]?.check_runs[]?], legacy_statuses: [$statuses[][]?] }, workflow_runs: [$workflow_runs[0].workflow_runs[]?], - counted_approvals: [ + counted_approvals: ([ $reviews[][]? | select(.state == "APPROVED") | select(.submitted_at != null) | select(.commit_id == $head) - ], + ] | length), required_workflows: [ $rules[][]? | select(.type == "workflows") From d0111fd5b6367d16247669cde5fad69a817e57e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:06:21 -0700 Subject: [PATCH 201/570] test(bap): require standard lifecycle error contracts --- .../tests/task_lifecycle_recovery.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/originweave-bap/tests/task_lifecycle_recovery.rs b/crates/originweave-bap/tests/task_lifecycle_recovery.rs index fb32ea06f..6d31b965e 100644 --- a/crates/originweave-bap/tests/task_lifecycle_recovery.rs +++ b/crates/originweave-bap/tests/task_lifecycle_recovery.rs @@ -1,5 +1,7 @@ #![allow(clippy::expect_used)] +use std::error::Error as _; + use originweave_bap::{ BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, BapTaskState, BapTaskTransitionError, }; @@ -98,3 +100,40 @@ fn restored_terminal_lifecycle_remains_terminal() { ); assert_eq!(task.transition_sequence(), 9); } + +#[test] +fn lifecycle_failures_use_the_standard_rust_error_contract() { + let mut created = BapTaskLifecycle::new(); + let invalid_transition = created + .apply(BapTaskEvent::Start) + .expect_err("created task must reject start"); + assert_eq!( + invalid_transition.to_string(), + "BAP task event Start is invalid from state Created" + ); + assert!(invalid_transition.source().is_none()); + + let exhausted = BapTaskTransitionError::SequenceExhausted; + assert_eq!( + exhausted.to_string(), + "BAP task transition sequence is exhausted" + ); + assert!(exhausted.source().is_none()); + + let terminal = BapTaskTransitionError::TerminalState { + state: BapTaskState::Cancelled, + }; + assert_eq!( + terminal.to_string(), + "BAP task state Cancelled is terminal" + ); + assert!(terminal.source().is_none()); + + let restore = BapTaskLifecycle::restore(BapTaskState::Created, 1) + .expect_err("unreachable snapshot must fail"); + assert_eq!( + restore.to_string(), + "BAP task snapshot state Created with transition sequence 1 is unreachable" + ); + assert!(restore.source().is_none()); +} From 0e7ee30052de8fd52d378c3c71cf1b85c94013dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:08:27 -0700 Subject: [PATCH 202/570] style(bap): apply canonical lifecycle test formatting --- crates/originweave-bap/tests/task_lifecycle_recovery.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/originweave-bap/tests/task_lifecycle_recovery.rs b/crates/originweave-bap/tests/task_lifecycle_recovery.rs index 6d31b965e..6b1240b90 100644 --- a/crates/originweave-bap/tests/task_lifecycle_recovery.rs +++ b/crates/originweave-bap/tests/task_lifecycle_recovery.rs @@ -123,10 +123,7 @@ fn lifecycle_failures_use_the_standard_rust_error_contract() { let terminal = BapTaskTransitionError::TerminalState { state: BapTaskState::Cancelled, }; - assert_eq!( - terminal.to_string(), - "BAP task state Cancelled is terminal" - ); + assert_eq!(terminal.to_string(), "BAP task state Cancelled is terminal"); assert!(terminal.source().is_none()); let restore = BapTaskLifecycle::restore(BapTaskState::Created, 1) From 5d6b8fd14051560a4b6ec99d4f0d2219396b4a48 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:16:54 -0700 Subject: [PATCH 203/570] fix(bap): implement standard lifecycle errors --- crates/originweave-bap/src/lib.rs | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index 37e2432c2..413e1c2bb 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -88,6 +88,25 @@ pub enum BapTaskTransitionError { }, } +impl std::fmt::Display for BapTaskTransitionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidTransition { from, event } => { + write!( + formatter, + "BAP task event {event:?} is invalid from state {from:?}" + ) + } + Self::SequenceExhausted => write!(formatter, "BAP task transition sequence is exhausted"), + Self::TerminalState { state } => { + write!(formatter, "BAP task state {state:?} is terminal") + } + } + } +} + +impl std::error::Error for BapTaskTransitionError {} + /// A fail-closed lifecycle recovery failure. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BapTaskRestoreError { @@ -100,6 +119,22 @@ pub enum BapTaskRestoreError { }, } +impl std::fmt::Display for BapTaskRestoreError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidSnapshot { + state, + transition_sequence, + } => write!( + formatter, + "BAP task snapshot state {state:?} with transition sequence {transition_sequence} is unreachable" + ), + } + } +} + +impl std::error::Error for BapTaskRestoreError {} + /// Immutable receipt for one accepted in-memory lifecycle transition. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BapTaskTransition { From b336a361e4b1631b9f748da133b512707686e1fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:19:31 -0700 Subject: [PATCH 204/570] test(evidence): require explicit extraction normalization --- .../tests/extraction_normalization.rs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 crates/originweave-evidence/tests/extraction_normalization.rs diff --git a/crates/originweave-evidence/tests/extraction_normalization.rs b/crates/originweave-evidence/tests/extraction_normalization.rs new file mode 100644 index 000000000..112995b65 --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_normalization.rs @@ -0,0 +1,78 @@ +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchemaError, + ExtractionSourceChannel, ExtractionValueType, +}; + +#[test] +fn extraction_fields_require_an_explicit_typed_normalization_rule() +-> Result<(), ExtractionSchemaError> { + let text = ExtractionField::new_with_normalization( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::TrimTextWhitespace, + &[ExtractionSourceChannel::SemanticNode], + )?; + assert_eq!( + text.normalization_rule(), + ExtractionNormalizationRule::TrimTextWhitespace + ); + + let timestamp = ExtractionField::new_with_normalization( + "captured_at", + ExtractionValueType::Timestamp, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::Rfc3339Utc, + &[ExtractionSourceChannel::NetworkResponse], + )?; + assert_eq!( + timestamp.normalization_rule(), + ExtractionNormalizationRule::Rfc3339Utc + ); + Ok(()) +} + +#[test] +fn extraction_fields_fail_closed_on_type_incompatible_normalization() { + assert_eq!( + ExtractionField::new_with_normalization( + "captured_at", + ExtractionValueType::Timestamp, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::TrimTextWhitespace, + &[ExtractionSourceChannel::NetworkResponse], + ), + Err(ExtractionSchemaError::InvalidNormalizationRule) + ); + assert_eq!( + ExtractionField::new_with_normalization( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::Rfc3339Utc, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidNormalizationRule) + ); +} + +#[test] +fn existing_fields_default_to_verbatim_normalization() +-> Result<(), ExtractionSchemaError> { + let field = ExtractionField::new( + "unit_price", + ExtractionValueType::Decimal, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::StructuredData], + )?; + assert_eq!( + field.normalization_rule(), + ExtractionNormalizationRule::Verbatim + ); + Ok(()) +} From c3b6e1a475dce333f6115e5113cae9c07974835f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:21:26 -0700 Subject: [PATCH 205/570] style(bap): apply canonical rustfmt layout --- crates/originweave-bap/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index 413e1c2bb..b2dbc6ba1 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -97,7 +97,9 @@ impl std::fmt::Display for BapTaskTransitionError { "BAP task event {event:?} is invalid from state {from:?}" ) } - Self::SequenceExhausted => write!(formatter, "BAP task transition sequence is exhausted"), + Self::SequenceExhausted => { + write!(formatter, "BAP task transition sequence is exhausted") + } Self::TerminalState { state } => { write!(formatter, "BAP task state {state:?} is terminal") } From 0325ad36175fee828f7552aa03c740464fcb9667 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:24:10 -0700 Subject: [PATCH 206/570] feat(evidence): bind extraction normalization rules --- .../src/extraction_schema.rs | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/originweave-evidence/src/extraction_schema.rs b/crates/originweave-evidence/src/extraction_schema.rs index e4d1b302c..f7ba37fc7 100644 --- a/crates/originweave-evidence/src/extraction_schema.rs +++ b/crates/originweave-evidence/src/extraction_schema.rs @@ -53,6 +53,17 @@ pub enum ExtractionSourceChannel { ModelInterpretation, } +/// A deterministic normalization rule declared for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionNormalizationRule { + /// Preserve the typed source value without text normalization. + Verbatim, + /// Trim surrounding whitespace from a textual value. + TrimTextWhitespace, + /// Normalize a timestamp into an RFC 3339 UTC representation. + Rfc3339Utc, +} + /// A validation failure while constructing an extraction schema contract. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExtractionSchemaError { @@ -64,6 +75,8 @@ pub enum ExtractionSchemaError { MissingSourceChannel, /// A field declared the same source channel more than once. DuplicateSourceChannel, + /// The declared normalization rule was incompatible with the field value type. + InvalidNormalizationRule, /// A schema did not contain any field definitions. MissingField, /// A schema declared the same field identifier more than once. @@ -77,23 +90,56 @@ pub struct ExtractionField { value_type: ExtractionValueType, cardinality: ExtractionCardinality, required: bool, + normalization_rule: ExtractionNormalizationRule, source_channels: Vec, } impl ExtractionField { - /// Validate and construct one extraction field contract. + /// Validate and construct one extraction field contract with verbatim normalization. pub fn new( identifier: &str, value_type: ExtractionValueType, cardinality: ExtractionCardinality, required: bool, source_channels: &[ExtractionSourceChannel], + ) -> Result { + Self::new_with_normalization( + identifier, + value_type, + cardinality, + required, + ExtractionNormalizationRule::Verbatim, + source_channels, + ) + } + + /// Validate and construct one extraction field with an explicit normalization rule. + pub fn new_with_normalization( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + normalization_rule: ExtractionNormalizationRule, + source_channels: &[ExtractionSourceChannel], ) -> Result { validate_identifier(identifier)?; if source_channels.is_empty() { return Err(ExtractionSchemaError::MissingSourceChannel); } + let normalization_is_compatible = match normalization_rule { + ExtractionNormalizationRule::Verbatim => true, + ExtractionNormalizationRule::TrimTextWhitespace => { + value_type == ExtractionValueType::Text + } + ExtractionNormalizationRule::Rfc3339Utc => { + value_type == ExtractionValueType::Timestamp + } + }; + if !normalization_is_compatible { + return Err(ExtractionSchemaError::InvalidNormalizationRule); + } + let mut seen_channels = BTreeSet::new(); for source_channel in source_channels { if !seen_channels.insert(*source_channel) { @@ -106,6 +152,7 @@ impl ExtractionField { value_type, cardinality, required, + normalization_rule, source_channels: source_channels.to_vec(), }) } @@ -134,6 +181,12 @@ impl ExtractionField { self.required } + /// Return the deterministic normalization rule declared for this field. + #[must_use] + pub const fn normalization_rule(&self) -> ExtractionNormalizationRule { + self.normalization_rule + } + /// Return the reviewed source channels that may support this field. #[must_use] pub fn source_channels(&self) -> &[ExtractionSourceChannel] { From 04299d12f5caca6cbd1af127a8fa6fd8908889e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:24:49 -0700 Subject: [PATCH 207/570] feat(evidence): export extraction normalization contract --- crates/originweave-evidence/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index c15d4ded8..6a719685e 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -11,8 +11,8 @@ mod extraction_schema; mod sensitive_access; pub use extraction_schema::{ - ExtractionCardinality, ExtractionField, ExtractionSchema, ExtractionSchemaError, - ExtractionSourceChannel, ExtractionValueType, MAX_EXTRACTION_FIELD_COUNT, + ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchema, + ExtractionSchemaError, ExtractionSourceChannel, ExtractionValueType, MAX_EXTRACTION_FIELD_COUNT, MAX_EXTRACTION_IDENTIFIER_BYTES, }; pub use sensitive_access::{ From f6901af8518fb39bb66cc4a5e062006ff6b39949 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:25:19 -0700 Subject: [PATCH 208/570] style(evidence): apply normalization regression rustfmt --- crates/originweave-evidence/tests/extraction_normalization.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-evidence/tests/extraction_normalization.rs b/crates/originweave-evidence/tests/extraction_normalization.rs index 112995b65..63afd39e6 100644 --- a/crates/originweave-evidence/tests/extraction_normalization.rs +++ b/crates/originweave-evidence/tests/extraction_normalization.rs @@ -61,8 +61,7 @@ fn extraction_fields_fail_closed_on_type_incompatible_normalization() { } #[test] -fn existing_fields_default_to_verbatim_normalization() --> Result<(), ExtractionSchemaError> { +fn existing_fields_default_to_verbatim_normalization() -> Result<(), ExtractionSchemaError> { let field = ExtractionField::new( "unit_price", ExtractionValueType::Decimal, From a527c433c62813d1346e050ce252b309b8d96b2b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:29:01 -0700 Subject: [PATCH 209/570] style(evidence): apply canonical normalization formatting --- crates/originweave-evidence/src/extraction_schema.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/originweave-evidence/src/extraction_schema.rs b/crates/originweave-evidence/src/extraction_schema.rs index f7ba37fc7..d8f978e74 100644 --- a/crates/originweave-evidence/src/extraction_schema.rs +++ b/crates/originweave-evidence/src/extraction_schema.rs @@ -132,9 +132,7 @@ impl ExtractionField { ExtractionNormalizationRule::TrimTextWhitespace => { value_type == ExtractionValueType::Text } - ExtractionNormalizationRule::Rfc3339Utc => { - value_type == ExtractionValueType::Timestamp - } + ExtractionNormalizationRule::Rfc3339Utc => value_type == ExtractionValueType::Timestamp, }; if !normalization_is_compatible { return Err(ExtractionSchemaError::InvalidNormalizationRule); From 69bc738bd45a1b61a4673b122dc3eec8814baa22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 16:29:49 -0700 Subject: [PATCH 210/570] style(evidence): apply canonical extraction export formatting --- crates/originweave-evidence/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index 6a719685e..05ae7c3f1 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -12,8 +12,8 @@ mod sensitive_access; pub use extraction_schema::{ ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchema, - ExtractionSchemaError, ExtractionSourceChannel, ExtractionValueType, MAX_EXTRACTION_FIELD_COUNT, - MAX_EXTRACTION_IDENTIFIER_BYTES, + ExtractionSchemaError, ExtractionSourceChannel, ExtractionValueType, + MAX_EXTRACTION_FIELD_COUNT, MAX_EXTRACTION_IDENTIFIER_BYTES, }; pub use sensitive_access::{ MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass, From 1a985420c92fbbc0bcd4b4ce9e1931c3f047a988 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 08:49:56 +0900 Subject: [PATCH 211/570] fix(docs): flatten paginated workflow evidence --- CHANGELOG.md | 1 + docs/product-technical-gap-baseline.md | 2 +- tests/test_product_completion_gap_contract.py | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a10bc3f0..a80657218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Separated hourly product PR publication authority from the organization review and merge system, and added live default-branch and release-blocker rechecks immediately before publication. - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. - Hardened the dated baseline evidence collector with fail-fast isolated artifacts, paginated branch and collaborator rules, and post-collection exact-head revalidation. +- Flattened every paginated workflow-run page in the baseline merge verdict so exact-head evidence cannot silently discard later runs. - Hardened the baseline evidence procedure with exact-head legacy status and workflow-run capture, counted approval binding, required-workflow recording, merge verdict artifacts, and bounded moving-head retries. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 9263c9e63..233338aa7 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -192,7 +192,7 @@ query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { check_runs: [$checks[]?.check_runs[]?], legacy_statuses: [$statuses[][]?] }, - workflow_runs: [$workflow_runs[0].workflow_runs[]?], + workflow_runs: [$workflow_runs[]?.workflow_runs[]?], counted_approvals: ([ $reviews[][]? | select(.state == "APPROVED") diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index dee74a62a..310573239 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -61,6 +61,7 @@ def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/statuses?per_page=100"', '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100"', '"repos/ContextualWisdomLab/OriginWeave/actions/runs?head_sha=$HEAD_SHA&per_page=100"', + "workflow_runs: [$workflow_runs[]?.workflow_runs[]?],", "reviewThreads(first: 100, after: $endCursor)", "rules/branches/main?per_page=100", '"$EVIDENCE_DIR/main-branch-rule-pages.json"', From 65d234beb00af328507359c1ba92bb90f55a1097 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 08:53:30 +0900 Subject: [PATCH 212/570] docs: refresh baseline inventory counts --- CHANGELOG.md | 2 +- docs/product-technical-gap-baseline.md | 8 ++++---- tests/test_product_completion_gap_contract.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a80657218..f6bfcc8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. - Tightened the product-baseline contract so the BiDi opening path and VPN/profile evidence retain their explicit not-shipped status within their own documentation sections. -- Refreshed the product and technical gap baseline against the 2026-08-21 live inventory: 149 open pull requests, 111 drafts, and the new hardened-runner/MV3 evidence gap issue #206. +- Refreshed the product and technical gap baseline against the 2026-08-21 live inventory: 148 open pull requests, 110 drafts, and the new hardened-runner/MV3 evidence gap issue #206. - Refreshed the baseline's merge-authority statement to the live ruleset: two approving reviews are required, while the collaborator inventory still contains only the solo maintainer. ### Security diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 233338aa7..d92baf799 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -14,14 +14,14 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, a ### Open pull requests -The live repository contained **149 open pull requests: 38 non-draft and 111 draft**. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **148 open pull requests: 38 non-draft and 110 draft**. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. Representative active workstreams at this snapshot were: | Workstream | Representative active PR evidence | Delivery boundary | |---|---|---| | Product baseline | #196 | Ready/non-draft documentation PR; this refreshed inventory and the completion issues below remain review-gated | -| WebDriver BiDi transport | #188 through #198 | #198, exact head `924f260cac885a8c66c81de1101c1ba183d00e74`, validates the RFC 6455 opening response on top of #195; the stack still does not by itself complete framed BiDi browser commands, authenticated browser-process provenance, semantic task execution, or protected-main shipment | +| WebDriver BiDi transport | #188 through #198 | #198, exact head `2e01bcd0fb3057b4a78c2f5dd58a5efd86bc26f2`, validates the RFC 6455 opening response on top of #195; the stack still does not by itself complete framed BiDi browser commands, authenticated browser-process provenance, semantic task execution, or protected-main shipment | | MCP adapter | #168 and #170 | Typed MCP routing and conservative `tools/list` metadata are active-PR foundations; complete authenticated transport, durable task lifecycle, cancellation/resume, and browser execution remain open under #200 | | Controlled Chromium and recovery | #65, #70-#73, #100, #105, #142-#153 and descendants | Real pinned-browser fixture, semantic location, resource, crash, and teardown evidence exists on active stacks; evidence does not transfer across heads or prerequisites | | Manifest V3 and native messaging | #27 and its active extension/native-host stack, including #154 and #169 | Compatibility and Agent-authority isolation remain incomplete until exact release artifacts and platform matrices are proven | @@ -80,7 +80,7 @@ The hourly product-development loop is operational infrastructure, not proof tha | P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | | P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | | P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | -| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 149-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 148-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | ## Commercial completion definition @@ -99,7 +99,7 @@ OriginWeave is not complete merely because every low-level primitive exists in s ## Next executable queue -1. Re-fetch all 149 PRs and compute the dependency graph, exact heads/bases, reviews, unresolved threads, current required checks, duplicate/supersession relationships, and branch ancestry. +1. Re-fetch all 148 PRs and compute the dependency graph, exact heads/bases, reviews, unresolved threads, current required checks, duplicate/supersession relationships, and branch ancestry. 2. Integrate merge-ready root PRs first; restack and independently revalidate only the immediate children. Close obsolete alternatives instead of carrying parallel truth. 3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #195/#198 WebSocket opening path and the remaining framed BiDi command/response, semantic observation, policy, action, post-condition, and recovery boundaries. 4. Finish #27 and #10 as separate security tracks; neither should be hidden inside the first browser PR. diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 310573239..156fc7026 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -17,9 +17,9 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: text = BASELINE.read_text(encoding="utf-8") for phrase in ( - "149 open pull requests", + "148 open pull requests", "38 non-draft", - "111 draft", + "110 draft", "#198", "#199", "#200", From f3c63d78432facaecfb89b3f3c93c332dd3f9d3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:31:48 -0700 Subject: [PATCH 213/570] test(network): enforce one BiDi exchange deadline --- .../webdriver_bidi_locate_nodes_exchange.rs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 232f57438..9428a5ba5 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -132,7 +132,28 @@ mod tests { use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; - use super::WebDriverBiDiLocateNodesExchangeError; + use super::{remaining_exchange_budget, WebDriverBiDiLocateNodesExchangeError}; + + #[test] + fn exchange_budget_consumes_elapsed_time_instead_of_resetting_for_read() { + let total = Duration::from_millis(500); + assert_eq!( + remaining_exchange_budget(total, Duration::from_millis(175)), + Ok(Duration::from_millis(325)) + ); + assert!(matches!( + remaining_exchange_budget(total, total), + Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + }) if exchange_timeout == total + )); + assert!(matches!( + remaining_exchange_budget(total, Duration::from_millis(501)), + Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + }) if exchange_timeout == total + )); + } #[test] fn exchange_errors_preserve_typed_sources_and_protocol_shape() { From ef4393d1a0bf793fa7524da699060ce36599d7c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:34:10 -0700 Subject: [PATCH 214/570] test(network): format exchange-deadline RED regression --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 9428a5ba5..7aac09edb 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -132,7 +132,7 @@ mod tests { use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; - use super::{remaining_exchange_budget, WebDriverBiDiLocateNodesExchangeError}; + use super::{WebDriverBiDiLocateNodesExchangeError, remaining_exchange_budget}; #[test] fn exchange_budget_consumes_elapsed_time_instead_of_resetting_for_read() { From 746128ca6febcd15e363faf641c651488d86b97c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:36:41 -0700 Subject: [PATCH 215/570] fix(network): consume one BiDi exchange deadline --- .../webdriver_bidi_locate_nodes_exchange.rs | 62 ++++++++++++++----- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 7aac09edb..ad78316fd 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -1,4 +1,8 @@ -use std::{error::Error, fmt, time::Duration}; +use std::{ + error::Error, + fmt, + time::{Duration, Instant}, +}; use originweave_core::{ BoundedWebDriverBiDiResponseDocument, ValidatedWebDriverBiDiLocateNodesResult, @@ -16,12 +20,17 @@ use crate::webdriver_bidi_websocket_handshake::{ /// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded /// WebSocket error, raw response bytes must pass the core pre-parser admission contract, and the /// admitted document must correlate to the exact consumed command before result nodes are returned. -/// An unexpected frame shape has no nested source because it is a protocol-shape refusal rather than -/// an underlying I/O or parser failure. +/// Protocol-shape and exhausted-deadline refusals have no nested source because neither masks an +/// underlying I/O or parser failure. #[derive(Debug)] pub enum WebDriverBiDiLocateNodesExchangeError { /// Bounded WebSocket frame write or read failed. Frame(WebDriverBiDiWebSocketFrameError), + /// The single end-to-end exchange deadline was exhausted before response read could proceed. + ExchangeDeadlineExceeded { + /// Original caller-supplied deadline budget for the complete write/read exchange. + exchange_timeout: Duration, + }, /// The first returned frame was not one complete text message. UnexpectedResponseFrame { /// Whether the returned frame carried the RFC 6455 FIN bit. @@ -42,6 +51,10 @@ impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { formatter, "WebDriver BiDi locateNodes WebSocket frame exchange failed: {error}" ), + Self::ExchangeDeadlineExceeded { exchange_timeout } => write!( + formatter, + "WebDriver BiDi locateNodes exchange exhausted its {exchange_timeout:?} end-to-end deadline before response read" + ), Self::UnexpectedResponseFrame { fin, opcode } => write!( formatter, "WebDriver BiDi locateNodes exchange requires one final text response frame; received fin={fin}, opcode=0x{opcode:02x}" @@ -64,11 +77,23 @@ impl Error for WebDriverBiDiLocateNodesExchangeError { Self::Frame(error) => Some(error), Self::ResponseDocument(error) => Some(error), Self::LocateNodesResponse(error) => Some(error), - Self::UnexpectedResponseFrame { .. } => None, + Self::ExchangeDeadlineExceeded { .. } | Self::UnexpectedResponseFrame { .. } => None, } } } +fn remaining_exchange_budget( + exchange_timeout: Duration, + elapsed: Duration, +) -> Result { + match exchange_timeout.checked_sub(elapsed) { + Some(remaining) if !remaining.is_zero() => Ok(remaining), + Some(_) | None => Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout, + }), + } +} + impl WebDriverBiDiWebSocketEstablished { /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. /// @@ -79,10 +104,11 @@ impl WebDriverBiDiWebSocketEstablished { /// Its exact payload bytes then pass the existing bounded UTF-8/document admission, complete /// WebDriver BiDi response parser, exact command-id correlation, and wire-derived node admission. /// - /// `frame_timeout` is independently enforced by the existing bounded write and bounded read - /// operations, so a successful exchange may consume up to two such operation budgets. Any - /// failure consumes this transport state and yields no reusable WebSocket stream, preventing a - /// partially written/read protocol state from being promoted into subsequent authority. + /// `exchange_timeout` is one end-to-end budget for the write/read exchange. The bounded write + /// receives that budget first; after it succeeds, elapsed time is subtracted and only the + /// positive remainder is supplied to the bounded read. The budget is never reset between those + /// operations. Any failure consumes this transport state and yields no reusable WebSocket stream, + /// preventing a partially written/read protocol state from being promoted into later authority. /// /// Success returns the same exact peer-verified WebSocket stream plus untrusted normalized node /// evidence. It does not authenticate Chromium/ChromeDriver process provenance, prove current @@ -92,16 +118,18 @@ impl WebDriverBiDiWebSocketEstablished { self, command: WebDriverBiDiLocateNodesCommand, masking_key: WebDriverBiDiWebSocketMaskKey, - frame_timeout: Duration, + exchange_timeout: Duration, ) -> Result< (Self, ValidatedWebDriverBiDiLocateNodesResult), WebDriverBiDiLocateNodesExchangeError, > { + let started_at = Instant::now(); let established = self - .write_text_frame(command.as_json(), masking_key, frame_timeout) + .write_text_frame(command.as_json(), masking_key, exchange_timeout) .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + let remaining_timeout = remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; let (established, frame) = established - .read_frame(frame_timeout) + .read_frame(remaining_timeout) .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; if !frame.fin() || frame.opcode() != 0x1 { @@ -137,10 +165,10 @@ mod tests { #[test] fn exchange_budget_consumes_elapsed_time_instead_of_resetting_for_read() { let total = Duration::from_millis(500); - assert_eq!( + assert!(matches!( remaining_exchange_budget(total, Duration::from_millis(175)), - Ok(Duration::from_millis(325)) - ); + Ok(remaining) if remaining == Duration::from_millis(325) + )); assert!(matches!( remaining_exchange_budget(total, total), Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { @@ -170,6 +198,12 @@ mod tests { .contains("WebSocket frame exchange failed") ); + let deadline = WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout: Duration::from_millis(500), + }; + assert!(deadline.source().is_none()); + assert!(deadline.to_string().contains("end-to-end deadline")); + let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { fin: false, opcode: 0x2, From 7daabb53e8ccb20cf9f1aa67cd9514d8221bf6fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 17:38:56 -0700 Subject: [PATCH 216/570] fix(network): format exchange deadline repair --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index ad78316fd..a34badd01 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -88,9 +88,9 @@ fn remaining_exchange_budget( ) -> Result { match exchange_timeout.checked_sub(elapsed) { Some(remaining) if !remaining.is_zero() => Ok(remaining), - Some(_) | None => Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { - exchange_timeout, - }), + Some(_) | None => Err( + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { exchange_timeout }, + ), } } From b3e738efdefe379750c9916f04261da2524ca94b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:40:05 +0900 Subject: [PATCH 217/570] docs: harden baseline evidence collection --- CHANGELOG.md | 3 +- docs/product-technical-gap-baseline.md | 109 +++++++++++++----- tests/test_product_completion_gap_contract.py | 26 ++++- 3 files changed, 101 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6bfcc8d0..3ac6294cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,8 +48,9 @@ All notable changes to OriginWeave are documented in this file. The format follo - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. - Tightened the product-baseline contract so the BiDi opening path and VPN/profile evidence retain their explicit not-shipped status within their own documentation sections. -- Refreshed the product and technical gap baseline against the 2026-08-21 live inventory: 148 open pull requests, 110 drafts, and the new hardened-runner/MV3 evidence gap issue #206. +- Refreshed the product and technical gap baseline against the 2026-08-21 live inventory: 150 open pull requests, 112 drafts, and the new hardened-runner/MV3 evidence gap issue #206. - Refreshed the baseline's merge-authority statement to the live ruleset: two approving reviews are required, while the collaborator inventory still contains only the solo maintainer. +- Corrected the baseline evidence collector to flatten every paginated input, apply current reviewer and last-push approval semantics, and discard verdicts when either the PR head or base moves. ### Security diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index d92baf799..f1bc22178 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -14,7 +14,7 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, a ### Open pull requests -The live repository contained **148 open pull requests: 38 non-draft and 110 draft**. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **150 open pull requests: 38 non-draft and 112 draft**. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. Representative active workstreams at this snapshot were: @@ -80,7 +80,7 @@ The hourly product-development loop is operational infrastructure, not proof tha | P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | | P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | | P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | -| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 148-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 150-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | ## Commercial completion definition @@ -99,7 +99,7 @@ OriginWeave is not complete merely because every low-level primitive exists in s ## Next executable queue -1. Re-fetch all 148 PRs and compute the dependency graph, exact heads/bases, reviews, unresolved threads, current required checks, duplicate/supersession relationships, and branch ancestry. +1. Re-fetch all 150 PRs and compute the dependency graph, exact heads/bases, reviews, unresolved threads, current required checks, duplicate/supersession relationships, and branch ancestry. 2. Integrate merge-ready root PRs first; restack and independently revalidate only the immediate children. Close obsolete alternatives instead of carrying parallel truth. 3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #195/#198 WebSocket opening path and the remaining framed BiDi command/response, semantic observation, policy, action, post-condition, and recovery boundaries. 4. Finish #27 and #10 as separate security tracks; neither should be hidden inside the first browser PR. @@ -144,9 +144,16 @@ jq '[.[][]]' "$EVIDENCE_DIR/collaborator-pages.json" \ jq -r '.[].number' "$EVIDENCE_DIR/open-prs.json" | while read -r PR; do STABLE_HEAD=false for ATTEMPT in 1 2 3; do + VERDICT_PATH="$EVIDENCE_DIR/pr-${PR}-merge-verdict.json" + VERDICT_TMP="$EVIDENCE_DIR/pr-${PR}-merge-verdict.json.tmp" + rm -f "$VERDICT_PATH" "$VERDICT_TMP" "$EVIDENCE_DIR/pr-${PR}-rechecked.json" PR_JSON="$EVIDENCE_DIR/pr-${PR}.json" gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" > "$PR_JSON" HEAD_SHA=$(jq -r '.head.sha' "$PR_JSON") + BASE_SHA=$(jq -r '.base.sha' "$PR_JSON") + + gh api "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA" \ + > "$EVIDENCE_DIR/pr-${PR}-head-commit.json" gh api --paginate --slurp \ "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100" \ @@ -184,48 +191,86 @@ query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { --slurpfile reviews "$EVIDENCE_DIR/pr-${PR}-reviews.json" \ --slurpfile workflow_runs "$EVIDENCE_DIR/pr-${PR}-workflow-runs.json" \ --slurpfile rules "$EVIDENCE_DIR/main-branch-rules.json" \ + --slurpfile collaborators "$EVIDENCE_DIR/collaborators.json" \ + --slurpfile head_commit "$EVIDENCE_DIR/pr-${PR}-head-commit.json" \ --slurpfile threads "$EVIDENCE_DIR/pr-${PR}-review-threads.json" \ - '{ - head_sha: $head, - base_sha: $pr[0].base.sha, - required_status_checks: { - check_runs: [$checks[]?.check_runs[]?], - legacy_statuses: [$statuses[][]?] - }, - workflow_runs: [$workflow_runs[]?.workflow_runs[]?], - counted_approvals: ([ - $reviews[][]? - | select(.state == "APPROVED") - | select(.submitted_at != null) - | select(.commit_id == $head) - ] | length), - required_workflows: [ + --arg base "$BASE_SHA" \ + '( + [ $rules[][]? - | select(.type == "workflows") - | .parameters.workflows[] - ], - unresolved_threads: [ - $threads[]?.data.repository.pullRequest.reviewThreads.nodes[]? - | select(.isResolved == false and .isOutdated == false) - ] - }' > "$EVIDENCE_DIR/pr-${PR}-merge-verdict.json" - + | select(.type == "pull_request") + | .parameters + ] | first // {} + ) as $pull_request_parameters + | ($head_commit[0].committer.login // $head_commit[0].author.login // "") as $last_push_actor + | ( + [ + $reviews[][][]? + | {reviewer: .user.login, state, submitted_at, commit_id} + | select(.submitted_at != null) + | select(.reviewer != $pr[0].user.login) + | select(.reviewer as $reviewer | + any($collaborators[][]?; + .login == $reviewer and + (.permissions.push == true or + .permissions.maintain == true or + .permissions.admin == true))) + ] + | group_by(.reviewer) + | map(sort_by(.submitted_at) | last) + | map(select( + ($pull_request_parameters.require_last_push_approval != true) + or .reviewer != $last_push_actor + )) + | map(select(.state == "APPROVED" and .commit_id == $head)) + ) as $current_approvals + | ($pull_request_parameters.required_approving_review_count // 0) as $required_review_count + | { + head_sha: $head, + base_sha: $base, + required_status_checks: { + check_runs: [$checks[][].check_runs[]?], + legacy_statuses: [$statuses[][][]?] + }, + workflow_runs: [$workflow_runs[][].workflow_runs[]?], + counted_approvals: ($current_approvals | length), + required_approving_review_count: $required_review_count, + require_last_push_approval: ($pull_request_parameters.require_last_push_approval // false), + approval_gate_satisfied: (($current_approvals | length) >= $required_review_count), + required_workflows: [ + $rules[][]? + | select(.type == "workflows") + | .parameters.workflows[] + ], + unresolved_threads: [ + $threads[][].data.repository.pullRequest.reviewThreads.nodes[]? + | select(.isResolved == false and .isOutdated == false) + ] + }' > "$VERDICT_TMP" + + RECHECKED_PR_JSON="$EVIDENCE_DIR/pr-${PR}-rechecked.json" RECHECKED_HEAD_SHA=$(gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" \ + | tee "$RECHECKED_PR_JSON" \ | jq -r '.head.sha') - if [[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" ]]; then + RECHECKED_BASE_SHA=$(jq -r '.base.sha' "$RECHECKED_PR_JSON") + if [[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" && "$RECHECKED_BASE_SHA" == "$BASE_SHA" ]]; then + mv "$VERDICT_TMP" "$VERDICT_PATH" + mv "$RECHECKED_PR_JSON" "$PR_JSON" STABLE_HEAD=true break fi - printf 'Discarding moving-head evidence for PR #%s (%s -> %s) and retrying.\n' \ - "$PR" "$HEAD_SHA" "$RECHECKED_HEAD_SHA" >&2 + rm -f "$VERDICT_TMP" "$RECHECKED_PR_JSON" + printf 'Discarding moving head/base evidence for PR #%s (head %s -> %s, base %s -> %s) and retrying.\n' \ + "$PR" "$HEAD_SHA" "$RECHECKED_HEAD_SHA" "$BASE_SHA" "$RECHECKED_BASE_SHA" >&2 done if [[ "$STABLE_HEAD" != true ]]; then - printf 'Unable to collect stable exact-head evidence for PR #%s after 3 attempts.\n' "$PR" >&2 + rm -f "$EVIDENCE_DIR"/pr-${PR}-*.json + printf 'Unable to collect stable exact-head/base evidence for PR #%s after 3 attempts.\n' "$PR" >&2 exit 1 fi done ``` -The branch-scoped rules response determines the active rules affecting `main`; each PR's exact `HEAD_SHA` then determines which check runs, legacy statuses, workflow runs, reviews, and unresolved threads are current. The saved merge verdict binds counted approvals to `APPROVED`, non-null submission times, and the exact head, while preserving required workflow rules. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. Evidence is retained only when the post-collection `RECHECKED_HEAD_SHA` equals the collected `HEAD_SHA`; a moving head fails after three bounded attempts. +The branch-scoped rules response determines the active rules affecting `main`; each PR's exact `HEAD_SHA` then determines which check runs, legacy statuses, workflow runs, reviews, and unresolved threads are current. The saved merge verdict binds counted approvals to the latest review per eligible collaborator, excludes the PR author and (when required) the last-push actor, applies the required approval count and last-push rule, and requires `APPROVED` on the exact head. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. Evidence is retained only when both `RECHECKED_HEAD_SHA` and `RECHECKED_BASE_SHA` match the collected values; a moving head or base discards the temporary verdict, and three failed attempts leave no unstable merge verdict. For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 156fc7026..c9584cc4f 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -17,9 +17,9 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: text = BASELINE.read_text(encoding="utf-8") for phrase in ( - "148 open pull requests", + "150 open pull requests", "38 non-draft", - "110 draft", + "112 draft", "#198", "#199", "#200", @@ -39,6 +39,8 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: "100 open pull requests", "22 non-draft", "78 draft", + "148 open pull requests", + "110 draft", "79 draft PRs", ): with self.subTest(stale_phrase=stale_phrase): @@ -61,26 +63,42 @@ def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/statuses?per_page=100"', '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100"', '"repos/ContextualWisdomLab/OriginWeave/actions/runs?head_sha=$HEAD_SHA&per_page=100"', - "workflow_runs: [$workflow_runs[]?.workflow_runs[]?],", + '"$EVIDENCE_DIR/pr-${PR}-head-commit.json"', + "check_runs: [$checks[][].check_runs[]?],", + "legacy_statuses: [$statuses[][][]?]", + "workflow_runs: [$workflow_runs[][].workflow_runs[]?],", "reviewThreads(first: 100, after: $endCursor)", "rules/branches/main?per_page=100", '"$EVIDENCE_DIR/main-branch-rule-pages.json"', + '"$EVIDENCE_DIR/collaborator-pages.json"', + '"$EVIDENCE_DIR/collaborators.json"', + '"$EVIDENCE_DIR/pr-${PR}-merge-verdict.json.tmp"', '.state == "APPROVED"', ".submitted_at != null", ".commit_id == $head", + "group_by(.reviewer)", + "required_approving_review_count", + "require_last_push_approval", + "$head_commit[0].committer.login", + "$pr[0].user.login", '.type == "workflows"', ".parameters.workflows", "required_status_checks", '"$EVIDENCE_DIR/pr-${PR}-merge-verdict.json"', "for ATTEMPT in 1 2 3; do", "RECHECKED_HEAD_SHA=", - '[[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" ]]', + "RECHECKED_BASE_SHA=", + 'if [[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" && "$RECHECKED_BASE_SHA" == "$BASE_SHA" ]]; then', ): with self.subTest(phrase=phrase): self.assertIn(phrase, shell) self.assertNotIn("while :; do", shell) self.assertNotIn("/tmp/originweave-open-pr", shell) + self.assertNotIn("check_runs: [$checks[]?.check_runs[]?],", shell) + self.assertNotIn("legacy_statuses: [$statuses[][]?]", shell) + self.assertNotIn("workflow_runs: [$workflow_runs[]?.workflow_runs[]?],", shell) + self.assertNotIn("$reviews[][]?\n | select(.state", shell) if __name__ == "__main__": From c5746a61ede9e0214be9c1feeff7f4f1af790016 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:16:01 +0900 Subject: [PATCH 218/570] test(network): cover locateNodes exchange deadline --- .../webdriver_bidi_locate_nodes_exchange.rs | 34 ++++++++++--------- ...er_bidi_websocket_locate_nodes_exchange.rs | 34 ++++++++++++++++++- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index a34badd01..cd202fb69 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -165,22 +165,24 @@ mod tests { #[test] fn exchange_budget_consumes_elapsed_time_instead_of_resetting_for_read() { let total = Duration::from_millis(500); - assert!(matches!( - remaining_exchange_budget(total, Duration::from_millis(175)), - Ok(remaining) if remaining == Duration::from_millis(325) - )); - assert!(matches!( - remaining_exchange_budget(total, total), - Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { - exchange_timeout - }) if exchange_timeout == total - )); - assert!(matches!( - remaining_exchange_budget(total, Duration::from_millis(501)), - Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { - exchange_timeout - }) if exchange_timeout == total - )); + assert_eq!( + format!( + "{:?}", + remaining_exchange_budget(total, Duration::from_millis(175)) + ), + "Ok(325ms)" + ); + assert_eq!( + format!("{:?}", remaining_exchange_budget(total, total)), + "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" + ); + assert_eq!( + format!( + "{:?}", + remaining_exchange_budget(total, Duration::from_millis(501)) + ), + "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" + ); } #[test] diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index 31824d8fc..e2383c765 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -150,7 +150,8 @@ fn establish_with_server_frame(response_frame: &[u8]) -> EstablishedFixture { } fn locate_nodes_command() -> WebDriverBiDiLocateNodesCommand { - let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2); + let name = "x".repeat(512); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some(&name), 2); assert!(query.is_ok(), "{query:?}"); let Ok(query) = query else { unreachable!("asserted valid test query") @@ -260,6 +261,37 @@ fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_r } } +#[test] +fn exchange_deadline_is_not_reset_after_the_frame_write() { + let response_frame = server_frame(0x81, RESPONSE_DOCUMENT.as_bytes()); + assert!(response_frame.is_ok(), "{response_frame:?}"); + let Ok(response_frame) = response_frame else { + return; + }; + let fixture = establish_with_server_frame(&response_frame); + assert!(fixture.is_ok(), "{fixture:?}"); + let Ok((_, established, server)) = fixture else { + return; + }; + + let error = established.exchange_locate_nodes( + locate_nodes_command(), + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + Duration::from_micros(20), + ); + assert!(error.is_err(), "{error:?}"); + let Err(error) = error else { + unreachable!("asserted exhausted exchange deadline") + }; + assert_eq!( + error.to_string(), + "WebDriver BiDi locateNodes exchange exhausted its 20µs end-to-end deadline before response read" + ); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); +} + #[test] fn exchange_rejects_a_non_final_or_non_text_response_frame() { for (first_byte, expected_fin, expected_opcode) in From 1c2770263e0173fef598dca1d0967e2f41355b6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:45:56 +0900 Subject: [PATCH 219/570] docs: refresh pull request queue baseline --- docs/product-technical-gap-baseline.md | 2 +- tests/test_product_completion_gap_contract.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f1bc22178..cf8c50819 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -14,7 +14,7 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, a ### Open pull requests -The live repository contained **150 open pull requests: 38 non-draft and 112 draft**. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **150 open pull requests: 39 non-draft and 111 draft** after PR #70 moved to Ready for review. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. Representative active workstreams at this snapshot were: diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index c9584cc4f..074707fab 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -18,8 +18,8 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: for phrase in ( "150 open pull requests", - "38 non-draft", - "112 draft", + "39 non-draft", + "111 draft", "#198", "#199", "#200", From c9874011fa7ca788cbbfc8bc8453cd4788f54e4c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:54:23 +0900 Subject: [PATCH 220/570] docs: refresh pull request queue baseline --- docs/product-technical-gap-baseline.md | 2 +- tests/test_product_completion_gap_contract.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index cf8c50819..026e09026 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -14,7 +14,7 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, a ### Open pull requests -The live repository contained **150 open pull requests: 39 non-draft and 111 draft** after PR #70 moved to Ready for review. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **150 open pull requests: 40 non-draft and 110 draft** after PRs #70 and #71 moved to Ready for review. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. Representative active workstreams at this snapshot were: diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 074707fab..4b7bb1d38 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -18,8 +18,8 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: for phrase in ( "150 open pull requests", - "39 non-draft", - "111 draft", + "40 non-draft", + "110 draft", "#198", "#199", "#200", @@ -40,7 +40,6 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: "22 non-draft", "78 draft", "148 open pull requests", - "110 draft", "79 draft PRs", ): with self.subTest(stale_phrase=stale_phrase): From c433d2711d233ec723edd3d8ad623ec4ce65af6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:42:13 +0900 Subject: [PATCH 221/570] docs: refresh active product gap evidence --- CHANGELOG.md | 1 + docs/product-technical-gap-baseline.md | 21 ++++++++++++++++--- ...cumentation_active_pr_evidence_contract.py | 15 +++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac6294cb..0bfec4954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Refreshed the product and technical gap baseline with the current open-PR inventory and exact base/head evidence for the newest Chromium, BAP, extraction, WARC, and idempotency slices. - 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. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 026e09026..e76daeaf8 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -14,21 +14,36 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, a ### Open pull requests -The live repository contained **150 open pull requests: 40 non-draft and 110 draft** after PRs #70 and #71 moved to Ready for review. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **150 open pull requests: 40 non-draft and 110 draft** after PRs #70 and #71 moved to Ready for review. The current snapshot also includes the newer #73 and #208–#211 product slices below. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. Representative active workstreams at this snapshot were: | Workstream | Representative active PR evidence | Delivery boundary | |---|---|---| | Product baseline | #196 | Ready/non-draft documentation PR; this refreshed inventory and the completion issues below remain review-gated | -| WebDriver BiDi transport | #188 through #198 | #198, exact head `2e01bcd0fb3057b4a78c2f5dd58a5efd86bc26f2`, validates the RFC 6455 opening response on top of #195; the stack still does not by itself complete framed BiDi browser commands, authenticated browser-process provenance, semantic task execution, or protected-main shipment | +| WebDriver BiDi transport | #188 through #205 | #205, exact head `c5746a61ede9e0214be9c1feeff7f4f1af790016`, exercises a bounded `locateNodes` exchange on top of the opening-path stack; the stack still does not by itself complete authenticated browser-process provenance, semantic task execution, or protected-main shipment | | MCP adapter | #168 and #170 | Typed MCP routing and conservative `tools/list` metadata are active-PR foundations; complete authenticated transport, durable task lifecycle, cancellation/resume, and browser execution remain open under #200 | | Controlled Chromium and recovery | #65, #70-#73, #100, #105, #142-#153 and descendants | Real pinned-browser fixture, semantic location, resource, crash, and teardown evidence exists on active stacks; evidence does not transfer across heads or prerequisites | +| BAP and durable-evidence tracks | #208-#211 | Resumable lifecycle, schema-bound extraction, bounded WARC resources, and exact idempotent receipts are active-PR foundations; authenticated transport, durable ownership, replay, and browser side-effect reconciliation remain open | | Manifest V3 and native messaging | #27 and its active extension/native-host stack, including #154 and #169 | Compatibility and Agent-authority isolation remain incomplete until exact release artifacts and platform matrices are proven | | Sensitive-data and model route policy | #10 and its active policy stacks | Deterministic policy values exist, but trusted broker execution, retention/deletion, runtime isolation, and auditable product workflows remain open | | VPN/profile intent | #149 | Bounded WireGuard/IKEv2 profile authority is active-PR evidence; it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof | -Draft PR #198 is the current top WebDriver BiDi opening-response slice; its prerequisite #195 owns the bounded opening-request write. It remains draft evidence and cannot be treated as shipped behavior. +Draft PR #205 is the current top WebDriver BiDi locate-nodes slice; its opening-path prerequisites #195 and #198 remain draft evidence and cannot be treated as shipped behavior. + +#### Current exact-head active PR evidence + +The following newest product slices were re-fetched from GitHub for this snapshot. Their exact base/head pairs are recorded so later checks, reviews, and restacks cannot be confused with predecessor evidence: + +| PR | State | Exact base head | Exact head | +|---|---|---|---| +| #73 | Draft | `da99395b09b419845b4a1222a0725482e9231466` | `7861d88d21ed0f0adaeb467957e809826f835071` | +| #208 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `c3b6e1a475dce333f6115e5113cae9c07974835f` | +| #209 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `69bc738bd45a1b61a4673b122dc3eec8814baa22` | +| #210 | Draft | `69bc738bd45a1b61a4673b122dc3eec8814baa22` | `999979a511c3a890ba93a1a09da8810858877940` | +| #211 | Draft | `c3b6e1a475dce333f6115e5113cae9c07974835f` | `f6e3a3adcfb9cc7a60ef1d79e2aeee27ba54c084` | + +These rows are delivery evidence only. #73's latest Strix remediation is locally verified but its required policy workflows remain queued; #208–#211 are stacked product-gap foundations with no protected-main promotion. None has counted independent approval in the current collaborator inventory. #### #195/#198 WebDriver BiDi opening path status diff --git a/tests/test_documentation_active_pr_evidence_contract.py b/tests/test_documentation_active_pr_evidence_contract.py index d2a067e50..466f1105c 100644 --- a/tests/test_documentation_active_pr_evidence_contract.py +++ b/tests/test_documentation_active_pr_evidence_contract.py @@ -8,6 +8,7 @@ DOCS = ROOT / "docs" FITNESS = DOCS / "DOCUMENTATION_FITNESS.md" MATURITY = DOCS / "evidence" / "2026-08-10-active-pr-maturity.md" +BASELINE = DOCS / "product-technical-gap-baseline.md" def active_pr_row(text: str, pr_number: int) -> str: @@ -28,6 +29,20 @@ class ActivePullRequestDocumentationContractTests(unittest.TestCase): def setUpClass(cls) -> None: cls.fitness = FITNESS.read_text(encoding="utf-8") cls.maturity = MATURITY.read_text(encoding="utf-8") + cls.baseline = BASELINE.read_text(encoding="utf-8") + + def test_latest_live_pr_snapshot_is_recorded_in_the_product_baseline(self) -> None: + """The baseline must preserve exact heads for the newest active product slices.""" + for marker in ( + "Current exact-head active PR evidence", + "| #73 | Draft | `da99395b09b419845b4a1222a0725482e9231466` | `7861d88d21ed0f0adaeb467957e809826f835071` |", + "| #208 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `c3b6e1a475dce333f6115e5113cae9c07974835f` |", + "| #209 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `69bc738bd45a1b61a4673b122dc3eec8814baa22` |", + "| #210 | Draft | `69bc738bd45a1b61a4673b122dc3eec8814baa22` | `999979a511c3a890ba93a1a09da8810858877940` |", + "| #211 | Draft | `c3b6e1a475dce333f6115e5113cae9c07974835f` | `f6e3a3adcfb9cc7a60ef1d79e2aeee27ba54c084` |", + ): + with self.subTest(marker=marker): + self.assertIn(marker, self.baseline) def test_dependency_stacks_are_explicit_and_non_shipped(self) -> None: """Current browser, network, sensitive and compatibility stacks stay active-only.""" From bc6292b7cde2023870a6ad9f4154dde3d6d83d9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:44:40 +0900 Subject: [PATCH 222/570] docs: reconcile baseline changelog evidence --- CHANGELOG.md | 4 ++-- ...test_documentation_active_pr_evidence_contract.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bfec4954..f5d30d1f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added - Added a dated product and technical gap baseline that separates protected-main implementation truth, active pull-request evidence, live review/check blockers, and the next buyer-visible Phase 1 acceptance work. +- Refreshed the product and technical gap baseline with the current open-PR inventory and exact base/head evidence for the newest Chromium, BAP, extraction, WARC, and idempotency slices. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. - Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. @@ -34,7 +35,6 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed -- Refreshed the product and technical gap baseline with the current open-PR inventory and exact base/head evidence for the newest Chromium, BAP, extraction, WARC, and idempotency slices. - 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. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. @@ -49,7 +49,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. - Tightened the product-baseline contract so the BiDi opening path and VPN/profile evidence retain their explicit not-shipped status within their own documentation sections. -- Refreshed the product and technical gap baseline against the 2026-08-21 live inventory: 150 open pull requests, 112 drafts, and the new hardened-runner/MV3 evidence gap issue #206. +- Refreshed the product and technical gap baseline against the 2026-08-21 live inventory: 150 open pull requests, 110 drafts, and the new hardened-runner/MV3 evidence gap issue #206. - Refreshed the baseline's merge-authority statement to the live ruleset: two approving reviews are required, while the collaborator inventory still contains only the solo maintainer. - Corrected the baseline evidence collector to flatten every paginated input, apply current reviewer and last-push approval semantics, and discard verdicts when either the PR head or base moves. diff --git a/tests/test_documentation_active_pr_evidence_contract.py b/tests/test_documentation_active_pr_evidence_contract.py index 466f1105c..34e8a0238 100644 --- a/tests/test_documentation_active_pr_evidence_contract.py +++ b/tests/test_documentation_active_pr_evidence_contract.py @@ -9,6 +9,7 @@ FITNESS = DOCS / "DOCUMENTATION_FITNESS.md" MATURITY = DOCS / "evidence" / "2026-08-10-active-pr-maturity.md" BASELINE = DOCS / "product-technical-gap-baseline.md" +CHANGELOG = ROOT / "CHANGELOG.md" def active_pr_row(text: str, pr_number: int) -> str: @@ -30,6 +31,7 @@ def setUpClass(cls) -> None: cls.fitness = FITNESS.read_text(encoding="utf-8") cls.maturity = MATURITY.read_text(encoding="utf-8") cls.baseline = BASELINE.read_text(encoding="utf-8") + cls.changelog = CHANGELOG.read_text(encoding="utf-8") def test_latest_live_pr_snapshot_is_recorded_in_the_product_baseline(self) -> None: """The baseline must preserve exact heads for the newest active product slices.""" @@ -44,6 +46,16 @@ def test_latest_live_pr_snapshot_is_recorded_in_the_product_baseline(self) -> No with self.subTest(marker=marker): self.assertIn(marker, self.baseline) + def test_baseline_refresh_changelog_matches_the_live_snapshot(self) -> None: + """The changelog must classify and state the same baseline refresh.""" + refresh = "Refreshed the product and technical gap baseline with the current open-PR inventory" + added = self.changelog.split("### Added", 1)[1].split("### Changed", 1)[0] + changed = self.changelog.split("### Changed", 1)[1].split("### Security", 1)[0] + self.assertIn(refresh, added) + self.assertNotIn(refresh, changed) + self.assertIn("150 open pull requests, 110 drafts", self.changelog) + self.assertNotIn("150 open pull requests, 112 drafts", self.changelog) + def test_dependency_stacks_are_explicit_and_non_shipped(self) -> None: """Current browser, network, sensitive and compatibility stacks stay active-only.""" for pr_number in (52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66): From 6e526fa90d93a01b744090dcce8daf72970d01dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 21:04:34 -0700 Subject: [PATCH 223/570] docs(adr): bind extraction schema version semantics --- docs/adr/0106-provenance-evidence-model.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/adr/0106-provenance-evidence-model.md b/docs/adr/0106-provenance-evidence-model.md index 0e2741f37..60dbb929c 100644 --- a/docs/adr/0106-provenance-evidence-model.md +++ b/docs/adr/0106-provenance-evidence-model.md @@ -33,29 +33,47 @@ OriginWeave maintains provenance-native evidence with stable identifiers for ses WARC and PROV are interoperability/export contracts, not substitutes for OriginWeave's internal authorization or evidence schema. A WARC record can contain untrusted or sensitive payload bytes and therefore inherits capture, retention, encryption, and export policy. A PROV entity/activity/agent relation records derivation or responsibility; it cannot manufacture authentication, authorization, durable completion, or tenant ownership not established by the producing system. +### Versioned extraction-schema binding + +A versioned `ExtractionSchema` is the binding contract for typed extraction before any capture persistence or export format is allowed to claim semantic authority. Each schema version contains an ordered, non-empty set of unique `ExtractionField` definitions. Schema-version and field identifiers are bounded to 128 encoded bytes, begin with a lowercase ASCII letter, and thereafter admit only lowercase ASCII letters, digits, `_`, or `-`. One schema admits at most 256 fields. + +Every extraction field binds its stable identifier to a value type, cardinality, required/optional status, deterministic normalization rule, and a non-empty duplicate-free set of reviewed source-channel classes. `Verbatim` is the compatibility default used by the existing constructor. `TrimTextWhitespace` is admitted only for text fields and `Rfc3339Utc` only for timestamp fields; type-incompatible normalization fails closed. A `ModelInterpretation` source channel is classification metadata only and does not grant model execution, approval, disclosure, browser, network, secret, or storage authority. + +At this value-object boundary, the version identifier is immutable schema identity; there is deliberately no registry that silently treats two different field contracts as compatible merely because their version strings compare or sort in a particular way. Callers changing a field identifier, value type, cardinality, required status, normalization rule, or admitted source-channel set must use a distinct reviewed schema version and perform any migration/compatibility decision at an explicit higher layer. The current schema object does not itself read browser data, materialize extracted values, persist artifacts, execute models, or change governance policy. Those capabilities require separately authorized runtime boundaries and are not implied by schema construction. + ## Consequences Capture becomes a designed product surface rather than incidental logging. Storage and retention need budgets. Consumers can distinguish a model claim from source evidence and an action request from verified completion. Export adapters can target WARC, provenance graphs, audit streams, or buyer-specific schemas. +A schema consumer can also determine the exact field/type/cardinality/normalization/source contract it reviewed rather than relying on free-form extraction instructions. Schema evolution is explicit instead of being inferred from mutable field definitions; runtime compatibility, migrations, durable storage, and extracted-value validation remain separate implementation work until those boundaries are delivered. + ## Failure and degraded behavior If mandatory evidence cannot be recorded durably enough for a governed state-changing action, the action fails before execution or reports an explicit unverifiable failure; it is never marked proved. Read-only operations may degrade to reduced evidence only when the API contract declares that mode. Corrupt or incomplete evidence is quarantined rather than silently accepted. +Invalid or oversized extraction identifiers, empty or duplicate field sets, missing or duplicate source channels, and type-incompatible normalization rules fail during schema construction. A caller must not reinterpret such a failure as an empty/default-success schema or silently substitute another source channel. + ## Security / privacy / governance impact Evidence is tenant-scoped, selectively disclosed, encrypted as appropriate, retention-bounded, and auditable. Credential-bearing headers, cookies, secret values, and sensitive form data are excluded or transformed according to explicit schema policy. Integrity metadata and immutable artifact identities support tamper detection without claiming external certification. `docs/DATA_GOVERNANCE.md` defines the disclosure/retention boundary for protected content and derived artifacts. +The extraction-schema contract does not modify governance authority. It describes admissible typed fields and reviewed evidence-channel classes only. In particular, declaring `NetworkResponse` or `ModelInterpretation` does not authorize network access, model execution, protected-data disclosure, approvals, retention, or export; those remain governed by their existing owning boundaries. + ## Tests and acceptance evidence Require provenance-link tests, credential-leak tests, integrity/corruption tests, crash-recovery tests, WARC/export conformance where implemented, PROV relation/schema tests where implemented, retention/deletion tests, tenant-isolation tests, and end-to-end checks that state-changing actions link request, policy, approval, execution, and post-condition as separate records. Export tests must prove that disabled or unauthorized source bodies never appear merely because metadata provenance is exportable. +The extraction-schema boundary additionally requires tests for the identifier grammar and limits, field-count bound, duplicate identifiers, source-channel presence and uniqueness, every reviewed value/cardinality/source-channel variant, deterministic normalization selection, incompatible normalization rejection, and the backward-compatible `Verbatim` constructor default. + ## Migration and rollback Introduce stable evidence identifiers and schema versions before changing export formats. Migrations preserve old evidence semantics or explicitly mark unavailable fields. Rollback may revert an exporter but cannot collapse mandatory action and policy evidence into opaque logs. +Extraction contract changes that alter field identity or semantics require a new reviewed schema version rather than mutating the meaning of an existing version. Rolling back a consumer may stop accepting a newer version, but it must not reinterpret that newer contract as an older one or silently discard required fields. + ## Open follow-ups -Finalize canonical evidence schemas, content-retention defaults, signing/attestation strategy, cross-system export identifiers, and buyer-controlled disclosure policies. +Finalize canonical evidence schemas, content-retention defaults, signing/attestation strategy, cross-system export identifiers, and buyer-controlled disclosure policies. Add the runtime that validates concrete extracted values against an `ExtractionSchema`, plus explicit migration/compatibility policy when durable schema registration is introduced. ## Supersession / reversal conditions From 54ab03423ae7d87b1254f90b4aa706e9c5e37e92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:07:56 -0700 Subject: [PATCH 224/570] test(network): require RFC6455 pong control-frame write --- .../webdriver_bidi_websocket_pong_write.rs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs new file mode 100644 index 000000000..8b9ccecd1 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs @@ -0,0 +1,105 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn connect(endpoint: &str) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_pong(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x8a || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client did not send one final masked Pong frame", + )); + } + let payload_length = usize::from(header[1] & 0x7f); + if payload_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Pong payload exceeded the RFC 6455 control-frame bound", + )); + } + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +#[test] +fn established_stream_writes_masked_pong_with_exact_ping_payload() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_pong(&mut stream) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let pong_payload = b"peer-keepalive"; + let established = established.write_pong_frame( + pong_payload, + WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]), + Duration::from_millis(500), + )?; + assert_eq!( + established.transport_evidence().verified_peer().socket_addr(), + local_addr + ); + drop(established); + + let received = server + .join() + .map_err(|_| io::Error::other("WebSocket Pong test server panicked"))??; + assert_eq!(received, pong_payload); + Ok(()) +} From f9ba5734323f2976944876a108967f9e5e4a4810 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:09:09 -0700 Subject: [PATCH 225/570] test(network): format RFC6455 pong regression --- .../tests/webdriver_bidi_websocket_pong_write.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs index 8b9ccecd1..a7d52a1fa 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs @@ -15,7 +15,9 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; -fn connect(endpoint: &str) -> Result> { +fn connect( + endpoint: &str, +) -> Result> { let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; let correlated = admitted.correlate_session_id(SESSION_ID)?; let target = correlated.into_explicit_connect_target()?; @@ -92,7 +94,10 @@ fn established_stream_writes_masked_pong_with_exact_ping_payload() -> Result<(), Duration::from_millis(500), )?; assert_eq!( - established.transport_evidence().verified_peer().socket_addr(), + established + .transport_evidence() + .verified_peer() + .socket_addr(), local_addr ); drop(established); From 64b6d65f7183538d32fd3e1baef40191df0ffff0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:12:57 -0700 Subject: [PATCH 226/570] fix(network): add bounded RFC6455 Pong writer --- .../src/webdriver_bidi_websocket_control.rs | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_control.rs diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs new file mode 100644 index 000000000..4dd733494 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -0,0 +1,315 @@ +use std::{ + io::{self, Write}, + net::TcpStream, + thread, + time::{Duration, Instant}, +}; + +use crate::{ + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketMaskKey, +}; + +const MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES: usize = 125; + +fn validate_pong_parameters( + payload_bytes: usize, + frame_timeout: Duration, +) -> Result<(), WebDriverBiDiWebSocketFrameError> { + if frame_timeout.is_zero() || frame_timeout > MAX_WEBSOCKET_FRAME_TIMEOUT { + return Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }); + } + if payload_bytes > MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES { + return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes, + maximum_bytes: MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES, + }); + } + Ok(()) +} + +fn serialize_pong_frame( + payload: &[u8], + masking_key: WebDriverBiDiWebSocketMaskKey, +) -> Vec { + let mut frame = Vec::with_capacity(payload.len() + 6); + frame.push(0x8a); + frame.push(0x80 | payload.len() as u8); + frame.extend_from_slice(masking_key.as_bytes()); + frame.extend( + payload.iter().enumerate().map(|(index, byte)| { + byte ^ masking_key.as_bytes()[index % masking_key.as_bytes().len()] + }), + ); + frame +} + +trait PongFrameWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result; +} + +impl PongFrameWriter for TcpStream { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + TcpStream::set_write_timeout(self, timeout) + } + + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_pong_frame_with_clock( + writer: &mut dyn PongFrameWriter, + frame: &[u8], + frame_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result<(), WebDriverBiDiWebSocketFrameError> { + let deadline = now() + frame_timeout; + let mut bytes_written = 0; + while bytes_written < frame.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written, + source: io::Error::new(io::ErrorKind::TimedOut, "Pong frame write deadline elapsed"), + }); + } + writer + .set_write_timeout(Some(remaining)) + .map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written, + source, + } + })?; + match writer.write_frame_bytes(&frame[bytes_written..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written }); + } + Ok(written) => bytes_written += written, + Err(source) => { + if source.kind() == io::ErrorKind::Interrupted { + continue; + } + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) { + if deadline.saturating_duration_since(now()).is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written, + source, + }); + } + thread::sleep(Duration::from_millis(1)); + continue; + } + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written, + source, + }); + } + } + } + writer + .set_write_timeout(None) + .map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { source })?; + Ok(()) +} + +impl WebDriverBiDiWebSocketEstablished { + /// Write one final masked RFC 6455 Pong control frame on this verified stream. + /// + /// The payload is limited to the RFC 6455 control-frame maximum of 125 bytes. A caller that is + /// responding to Ping must pass the exact received Ping application data and a fresh, + /// unpredictable masking key dedicated to this client frame. The operation consumes established + /// state and returns it only after the complete frame is written within one monotonic bounded + /// deadline and the operation-local socket timeout is cleared. Failure yields no reusable stream. + /// This protocol response does not create browser, page, policy, origin, or Agent authority. + pub fn write_pong_frame( + mut self, + payload: &[u8], + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result { + validate_pong_parameters(payload.len(), frame_timeout)?; + let frame = serialize_pong_frame(payload, masking_key); + let mut now = Instant::now; + write_pong_frame_with_clock(&mut self.stream, &frame, frame_timeout, &mut now)?; + Ok(self) + } +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + + use super::*; + + #[derive(Debug)] + enum WriteAction { + Count(usize), + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeWriter { + timeout_error: Option, + cleanup_error: Option, + actions: VecDeque, + } + + impl FakeWriter { + fn new(actions: impl IntoIterator) -> Self { + Self { + timeout_error: None, + cleanup_error: None, + actions: actions.into_iter().collect(), + } + } + } + + impl PongFrameWriter for FakeWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + let error = if timeout.is_some() { + self.timeout_error + } else { + self.cleanup_error + }; + error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) + } + + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { + match self + .actions + .pop_front() + .unwrap_or(WriteAction::Count(bytes.len())) + { + WriteAction::Count(count) => Ok(count.min(bytes.len())), + WriteAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + fn write_with_fake( + writer: &mut FakeWriter, + now_values: impl IntoIterator, + ) -> Result<(), WebDriverBiDiWebSocketFrameError> { + let fallback = Instant::now(); + let mut now_values = now_values.into_iter(); + let mut now = || now_values.next().unwrap_or(fallback); + write_pong_frame_with_clock(writer, b"abcdef", Duration::from_secs(1), &mut now) + } + + #[test] + fn pong_parameter_validation_is_fail_closed() { + assert!(validate_pong_parameters(0, Duration::from_millis(1)).is_ok()); + assert!(matches!( + validate_pong_parameters(0, Duration::ZERO), + Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) + )); + assert!(matches!( + validate_pong_parameters( + 0, + MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1) + ), + Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) + )); + assert!(matches!( + validate_pong_parameters(126, Duration::from_millis(1)), + Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: 126, + maximum_bytes: MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES, + }) + )); + } + + #[test] + fn pong_serializer_emits_final_masked_control_frame() { + let key = WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let frame = serialize_pong_frame(b"abc", key); + assert_eq!(&frame[..6], &[0x8a, 0x83, 1, 2, 3, 4]); + assert_eq!(&frame[6..], &[b'a' ^ 1, b'b' ^ 2, b'c' ^ 3]); + } + + #[test] + fn pong_writer_handles_partial_interrupted_and_would_block_progress() { + let start = Instant::now(); + let mut partial = FakeWriter::new([WriteAction::Count(2), WriteAction::Count(4)]); + assert!(write_with_fake(&mut partial, [start, start, start]).is_ok()); + + let mut interrupted = FakeWriter::new([ + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(6), + ]); + assert!(write_with_fake(&mut interrupted, [start, start, start]).is_ok()); + + let mut would_block = FakeWriter::new([ + WriteAction::Error(io::ErrorKind::WouldBlock), + WriteAction::Count(6), + ]); + assert!( + write_with_fake(&mut would_block, [start, start, start, start]).is_ok() + ); + } + + #[test] + fn pong_writer_preserves_typed_write_failures() { + let start = Instant::now(); + let later = start + Duration::from_secs(1); + + let mut deadline = FakeWriter::new([]); + assert!(matches!( + write_with_fake(&mut deadline, [start, later]), + Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written: 0, + .. + }) + )); + + let mut configure = FakeWriter::new([]); + configure.timeout_error = Some(io::ErrorKind::PermissionDenied); + assert!(matches!( + write_with_fake(&mut configure, [start, start]), + Err(WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written: 0, + .. + }) + )); + + let mut zero = FakeWriter::new([WriteAction::Count(0)]); + assert!(matches!( + write_with_fake(&mut zero, [start, start]), + Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written: 0 }) + )); + + let mut timed_out = FakeWriter::new([WriteAction::Error(io::ErrorKind::TimedOut)]); + assert!(matches!( + write_with_fake(&mut timed_out, [start, start, later]), + Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written: 0, + .. + }) + )); + + let mut failed = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + assert!(matches!( + write_with_fake(&mut failed, [start, start]), + Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written: 0, + .. + }) + )); + + let mut cleanup = FakeWriter::new([WriteAction::Count(6)]); + cleanup.cleanup_error = Some(io::ErrorKind::PermissionDenied); + assert!(matches!( + write_with_fake(&mut cleanup, [start, start]), + Err(WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { .. }) + )); + } +} From 61af6b4962469750630733a6463e26a465457042 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:13:15 -0700 Subject: [PATCH 227/570] fix(network): enable bounded Pong control writer --- crates/originweave-network/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 139872e26..ddca0ec0a 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -15,6 +15,7 @@ mod connection; mod webdriver_bidi_connection; +mod webdriver_bidi_websocket_control; mod webdriver_bidi_websocket_handshake; pub use connection::{ From 38061f7859f960f0f8c3bb8bb02cdef544e46c44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:15:00 -0700 Subject: [PATCH 228/570] fix(network): format bounded Pong writer --- .../src/webdriver_bidi_websocket_control.rs | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs index 4dd733494..31c69a1c3 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -6,8 +6,8 @@ use std::{ }; use crate::{ - MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, - WebDriverBiDiWebSocketMaskKey, + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, }; const MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES: usize = 125; @@ -31,10 +31,7 @@ fn validate_pong_parameters( Ok(()) } -fn serialize_pong_frame( - payload: &[u8], - masking_key: WebDriverBiDiWebSocketMaskKey, -) -> Vec { +fn serialize_pong_frame(payload: &[u8], masking_key: WebDriverBiDiWebSocketMaskKey) -> Vec { let mut frame = Vec::with_capacity(payload.len() + 6); frame.push(0x8a); frame.push(0x80 | payload.len() as u8); @@ -75,7 +72,10 @@ fn write_pong_frame_with_clock( if remaining.is_zero() { return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { bytes_written, - source: io::Error::new(io::ErrorKind::TimedOut, "Pong frame write deadline elapsed"), + source: io::Error::new( + io::ErrorKind::TimedOut, + "Pong frame write deadline elapsed", + ), }); } writer @@ -213,10 +213,7 @@ mod tests { Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) )); assert!(matches!( - validate_pong_parameters( - 0, - MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1) - ), + validate_pong_parameters(0, MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1)), Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) )); assert!(matches!( @@ -252,9 +249,7 @@ mod tests { WriteAction::Error(io::ErrorKind::WouldBlock), WriteAction::Count(6), ]); - assert!( - write_with_fake(&mut would_block, [start, start, start, start]).is_ok() - ); + assert!(write_with_fake(&mut would_block, [start, start, start, start]).is_ok()); } #[test] @@ -275,10 +270,12 @@ mod tests { configure.timeout_error = Some(io::ErrorKind::PermissionDenied); assert!(matches!( write_with_fake(&mut configure, [start, start]), - Err(WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { - bytes_written: 0, - .. - }) + Err( + WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written: 0, + .. + } + ) )); let mut zero = FakeWriter::new([WriteAction::Count(0)]); From 9541b6d260683c4a6f6ac8f4e79993659a0224f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 02:40:58 -0700 Subject: [PATCH 229/570] test(network): require Pong during locateNodes exchange --- ...river_bidi_locate_nodes_ping_interleave.rs | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs new file mode 100644 index 000000000..ee8e896c3 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -0,0 +1,161 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const PING_PAYLOAD: &[u8] = b"keepalive"; + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_frame(stream: &mut TcpStream, expected_first_byte: u8) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != expected_first_byte || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client frame did not have the expected final opcode and masking bit", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture does not admit 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + if expected_first_byte == 0x8a && payload_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Pong payload exceeded the RFC 6455 control-frame bound", + )); + } + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +#[test] +fn locate_nodes_exchange_answers_interleaved_ping_before_admitting_response( +) -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<(Vec, Vec)> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let command = read_masked_client_frame(&mut stream, 0x81)?; + let mut ping_frame = vec![0x89, u8::try_from(PING_PAYLOAD.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "test Ping payload exceeded one-byte length") + })?]; + ping_frame.extend_from_slice(PING_PAYLOAD); + stream.write_all(&ping_frame)?; + let pong = read_masked_client_frame(&mut stream, 0x8a)?; + let response_length = u8::try_from(RESPONSE_DOCUMENT.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "test response exceeded one-byte length") + })?; + stream.write_all(&[0x81, response_length])?; + stream.write_all(RESPONSE_DOCUMENT.as_bytes())?; + Ok((command, pong)) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let command = locate_nodes_command()?; + let expected_command = command.as_json().as_bytes().to_vec(); + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("interleaved Ping test server panicked"))?; + assert!(exchanged.is_ok(), "{exchanged:?}"); + assert!(server_result.is_ok(), "{server_result:?}"); + let (received_command, received_pong) = server_result?; + assert_eq!(received_command, expected_command); + assert_eq!(received_pong, PING_PAYLOAD); + + let (established, result) = exchanged?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.nodes().len(), 1); + assert_eq!(result.nodes()[0].shared_id(), "shared-1"); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .socket_addr(), + local_addr + ); + Ok(()) +} From 1bd7d0222447c951f33f6bf1f193ac4b86bedd6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 02:43:55 -0700 Subject: [PATCH 230/570] fix(network): service BiDi control frames during locateNodes --- .../webdriver_bidi_locate_nodes_exchange.rs | 145 ++++++++++++------ ...river_bidi_locate_nodes_ping_interleave.rs | 15 +- ...er_bidi_websocket_locate_nodes_exchange.rs | 6 +- 3 files changed, 114 insertions(+), 52 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index cd202fb69..a28a84584 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -20,18 +20,20 @@ use crate::webdriver_bidi_websocket_handshake::{ /// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded /// WebSocket error, raw response bytes must pass the core pre-parser admission contract, and the /// admitted document must correlate to the exact consumed command before result nodes are returned. -/// Protocol-shape and exhausted-deadline refusals have no nested source because neither masks an -/// underlying I/O or parser failure. +/// Protocol-shape, exhausted-deadline, and missing caller entropy refusals have no nested source +/// because none masks an underlying I/O or parser failure. #[derive(Debug)] pub enum WebDriverBiDiLocateNodesExchangeError { /// Bounded WebSocket frame write or read failed. Frame(WebDriverBiDiWebSocketFrameError), - /// The single end-to-end exchange deadline was exhausted before response read could proceed. + /// The single end-to-end exchange deadline was exhausted before the next operation could proceed. ExchangeDeadlineExceeded { - /// Original caller-supplied deadline budget for the complete write/read exchange. + /// Original caller-supplied deadline budget for the complete exchange. exchange_timeout: Duration, }, - /// The first returned frame was not one complete text message. + /// A server Ping required a fresh client masking key, but the caller supplied none. + PongMaskingKeyUnavailable, + /// The returned frame was neither an admissible control frame nor one complete text response. UnexpectedResponseFrame { /// Whether the returned frame carried the RFC 6455 FIN bit. fin: bool, @@ -53,11 +55,14 @@ impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { ), Self::ExchangeDeadlineExceeded { exchange_timeout } => write!( formatter, - "WebDriver BiDi locateNodes exchange exhausted its {exchange_timeout:?} end-to-end deadline before response read" + "WebDriver BiDi locateNodes exchange exhausted its {exchange_timeout:?} end-to-end deadline before the next operation" + ), + Self::PongMaskingKeyUnavailable => formatter.write_str( + "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key", ), Self::UnexpectedResponseFrame { fin, opcode } => write!( formatter, - "WebDriver BiDi locateNodes exchange requires one final text response frame; received fin={fin}, opcode=0x{opcode:02x}" + "WebDriver BiDi locateNodes exchange requires control handling or one final text response frame; received fin={fin}, opcode=0x{opcode:02x}" ), Self::ResponseDocument(error) => write!( formatter, @@ -77,7 +82,9 @@ impl Error for WebDriverBiDiLocateNodesExchangeError { Self::Frame(error) => Some(error), Self::ResponseDocument(error) => Some(error), Self::LocateNodesResponse(error) => Some(error), - Self::ExchangeDeadlineExceeded { .. } | Self::UnexpectedResponseFrame { .. } => None, + Self::ExchangeDeadlineExceeded { .. } + | Self::PongMaskingKeyUnavailable + | Self::UnexpectedResponseFrame { .. } => None, } } } @@ -94,59 +101,88 @@ fn remaining_exchange_budget( } } +fn next_pong_masking_key( + next_key: &mut dyn FnMut() -> Option, +) -> Result { + next_key().ok_or(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) +} + impl WebDriverBiDiWebSocketEstablished { /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. /// /// The command is serialized by the reviewed core boundary and written as one masked client - /// text frame using the caller-supplied fresh masking key. The first returned server frame must - /// be a complete unmasked text frame (`FIN=1`, opcode `0x1`); continuation, binary, ping, pong, - /// close, and fragmented data fail closed rather than being reinterpreted as a BiDi response. - /// Its exact payload bytes then pass the existing bounded UTF-8/document admission, complete - /// WebDriver BiDi response parser, exact command-id correlation, and wire-derived node admission. + /// text frame using `command_masking_key`. Valid server Ping frames are answered with a masked + /// Pong carrying the exact Ping application data, while unsolicited valid Pong frames are + /// consumed without changing BiDi state. Each Ping obtains a fresh unpredictable client mask + /// from `next_pong_masking_key`; exhausting that caller-owned entropy source fails closed and + /// consumes the transport rather than reusing a masking key. Close, binary, continuation, + /// fragmented data, and reserved shapes are not reinterpreted as a BiDi response. /// - /// `exchange_timeout` is one end-to-end budget for the write/read exchange. The bounded write - /// receives that budget first; after it succeeds, elapsed time is subtracted and only the - /// positive remainder is supplied to the bounded read. The budget is never reset between those - /// operations. Any failure consumes this transport state and yields no reusable WebSocket stream, - /// preventing a partially written/read protocol state from being promoted into later authority. + /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, + /// and response read. Elapsed time is subtracted before every subsequent operation and the budget + /// is never reset. The underlying frame boundary independently caps each frame at its existing + /// size ceiling, while the single exchange deadline bounds a peer that sends repeated valid + /// control frames. Any failure consumes this transport state and yields no reusable WebSocket + /// stream, preventing a partially written/read protocol state from becoming later authority. /// - /// Success returns the same exact peer-verified WebSocket stream plus untrusted normalized node - /// evidence. It does not authenticate Chromium/ChromeDriver process provenance, prove current - /// OriginWeave session/context/origin/document authority, authorize policy or typed input, mint - /// node handles, execute a browser action, or prove a post-condition. + /// The final complete text payload passes the existing bounded UTF-8/document admission, + /// complete WebDriver BiDi response parser, exact command-id correlation, and wire-derived node + /// admission. Success returns the same exact peer-verified WebSocket stream plus untrusted + /// normalized node evidence. It does not authenticate Chromium/ChromeDriver process provenance, + /// prove current OriginWeave session/context/origin/document authority, authorize policy or typed + /// input, mint node handles, execute a browser action, or prove a post-condition. pub fn exchange_locate_nodes( self, command: WebDriverBiDiLocateNodesCommand, - masking_key: WebDriverBiDiWebSocketMaskKey, + command_masking_key: WebDriverBiDiWebSocketMaskKey, + mut next_pong_masking_key: impl FnMut() -> Option, exchange_timeout: Duration, ) -> Result< (Self, ValidatedWebDriverBiDiLocateNodesResult), WebDriverBiDiLocateNodesExchangeError, > { let started_at = Instant::now(); - let established = self - .write_text_frame(command.as_json(), masking_key, exchange_timeout) - .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; - let remaining_timeout = remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; - let (established, frame) = established - .read_frame(remaining_timeout) + let mut established = self + .write_text_frame(command.as_json(), command_masking_key, exchange_timeout) .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; - if !frame.fin() || frame.opcode() != 0x1 { - return Err( - WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { - fin: frame.fin(), - opcode: frame.opcode(), - }, - ); - } + loop { + let remaining_timeout = + remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; + let (next_established, frame) = established + .read_frame(remaining_timeout) + .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + established = next_established; - let document = BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(frame.payload()) - .map_err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument)?; - let result = command - .admit_response_document_nodes(document) - .map_err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse)?; - Ok((established, result)) + match frame.opcode() { + 0x9 => { + let masking_key = next_pong_masking_key(&mut next_pong_masking_key)?; + let remaining_timeout = + remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; + established = established + .write_pong_frame(frame.payload(), masking_key, remaining_timeout) + .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + } + 0xa => {} + 0x1 if frame.fin() => { + let document = + BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(frame.payload()) + .map_err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument)?; + let result = command + .admit_response_document_nodes(document) + .map_err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse)?; + return Ok((established, result)); + } + _ => { + return Err( + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: frame.fin(), + opcode: frame.opcode(), + }, + ); + } + } + } } } @@ -160,10 +196,12 @@ mod tests { use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; - use super::{WebDriverBiDiLocateNodesExchangeError, remaining_exchange_budget}; + use super::{ + WebDriverBiDiLocateNodesExchangeError, next_pong_masking_key, remaining_exchange_budget, + }; #[test] - fn exchange_budget_consumes_elapsed_time_instead_of_resetting_for_read() { + fn exchange_budget_consumes_elapsed_time_instead_of_resetting() { let total = Duration::from_millis(500); assert_eq!( format!( @@ -185,6 +223,19 @@ mod tests { ); } + #[test] + fn pong_masking_key_source_fails_closed_when_entropy_is_unavailable() { + let expected = crate::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let mut available = || Some(expected); + assert_eq!(next_pong_masking_key(&mut available).ok(), Some(expected)); + + let mut unavailable = || None; + assert!(matches!( + next_pong_masking_key(&mut unavailable), + Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) + )); + } + #[test] fn exchange_errors_preserve_typed_sources_and_protocol_shape() { let frame = WebDriverBiDiLocateNodesExchangeError::Frame( @@ -206,6 +257,10 @@ mod tests { assert!(deadline.source().is_none()); assert!(deadline.to_string().contains("end-to-end deadline")); + let missing_mask = WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable; + assert!(missing_mask.source().is_none()); + assert!(missing_mask.to_string().contains("fresh caller-supplied Pong masking key")); + let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { fin: false, opcode: 0x2, diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index ee8e896c3..05f1ce576 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -99,7 +99,7 @@ fn locate_nodes_command() -> Result Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; @@ -110,12 +110,13 @@ fn locate_nodes_exchange_answers_interleaved_ping_before_admitting_response( b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", )?; let command = read_masked_client_frame(&mut stream, 0x81)?; - let mut ping_frame = vec![0x89, u8::try_from(PING_PAYLOAD.len()).map_err(|_| { + let ping_length = u8::try_from(PING_PAYLOAD.len()).map_err(|_| { io::Error::new(io::ErrorKind::InvalidData, "test Ping payload exceeded one-byte length") - })?]; - ping_frame.extend_from_slice(PING_PAYLOAD); - stream.write_all(&ping_frame)?; + })?; + stream.write_all(&[0x89, ping_length])?; + stream.write_all(PING_PAYLOAD)?; let pong = read_masked_client_frame(&mut stream, 0x8a)?; + stream.write_all(&[0x8a, 0])?; let response_length = u8::try_from(RESPONSE_DOCUMENT.len()).map_err(|_| { io::Error::new(io::ErrorKind::InvalidData, "test response exceeded one-byte length") })?; @@ -131,15 +132,17 @@ fn locate_nodes_exchange_answers_interleaved_ping_before_admitting_response( let established = written.read_opening_response(Duration::from_millis(500))?; let command = locate_nodes_command()?; let expected_command = command.as_json().as_bytes().to_vec(); + let mut pong_keys = [WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54])].into_iter(); let exchanged = established.exchange_locate_nodes( command, WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + || pong_keys.next(), Duration::from_millis(500), ); let server_result = server .join() - .map_err(|_| io::Error::other("interleaved Ping test server panicked"))?; + .map_err(|_| io::Error::other("interleaved control-frame test server panicked"))?; assert!(exchanged.is_ok(), "{exchanged:?}"); assert!(server_result.is_ok(), "{server_result:?}"); let (received_command, received_pong) = server_result?; diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index e2383c765..62e4e95bb 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -177,6 +177,7 @@ fn exchange_error( let error = established.exchange_locate_nodes( locate_nodes_command(), WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + || None, frame_timeout, ); assert!(error.is_err(), "{error:?}"); @@ -224,6 +225,7 @@ fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_r let exchanged = established.exchange_locate_nodes( command, WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + || None, Duration::from_millis(500), ); assert!(exchanged.is_ok(), "{exchanged:?}"); @@ -277,6 +279,7 @@ fn exchange_deadline_is_not_reset_after_the_frame_write() { let error = established.exchange_locate_nodes( locate_nodes_command(), WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + || None, Duration::from_micros(20), ); assert!(error.is_err(), "{error:?}"); @@ -285,7 +288,7 @@ fn exchange_deadline_is_not_reset_after_the_frame_write() { }; assert_eq!( error.to_string(), - "WebDriver BiDi locateNodes exchange exhausted its 20µs end-to-end deadline before response read" + "WebDriver BiDi locateNodes exchange exhausted its 20µs end-to-end deadline before the next operation" ); let server_result = server.join(); @@ -311,6 +314,7 @@ fn exchange_rejects_a_non_final_or_non_text_response_frame() { let error = established.exchange_locate_nodes( locate_nodes_command(), WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + || None, Duration::from_millis(500), ); assert!(error.is_err(), "{error:?}"); From 10649054d7dd287038bbf9f2d72b3be216523a7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 02:48:46 -0700 Subject: [PATCH 231/570] fix(network): repair Pong exchange build contract --- .../webdriver_bidi_locate_nodes_exchange.rs | 12 ++++++++---- ...river_bidi_locate_nodes_ping_interleave.rs | 19 ++++++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index a28a84584..c6b38dc57 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -114,7 +114,7 @@ impl WebDriverBiDiWebSocketEstablished { /// text frame using `command_masking_key`. Valid server Ping frames are answered with a masked /// Pong carrying the exact Ping application data, while unsolicited valid Pong frames are /// consumed without changing BiDi state. Each Ping obtains a fresh unpredictable client mask - /// from `next_pong_masking_key`; exhausting that caller-owned entropy source fails closed and + /// from `next_pong_key`; exhausting that caller-owned entropy source fails closed and /// consumes the transport rather than reusing a masking key. Close, binary, continuation, /// fragmented data, and reserved shapes are not reinterpreted as a BiDi response. /// @@ -135,7 +135,7 @@ impl WebDriverBiDiWebSocketEstablished { self, command: WebDriverBiDiLocateNodesCommand, command_masking_key: WebDriverBiDiWebSocketMaskKey, - mut next_pong_masking_key: impl FnMut() -> Option, + mut next_pong_key: impl FnMut() -> Option, exchange_timeout: Duration, ) -> Result< (Self, ValidatedWebDriverBiDiLocateNodesResult), @@ -156,7 +156,7 @@ impl WebDriverBiDiWebSocketEstablished { match frame.opcode() { 0x9 => { - let masking_key = next_pong_masking_key(&mut next_pong_masking_key)?; + let masking_key = next_pong_masking_key(&mut next_pong_key)?; let remaining_timeout = remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; established = established @@ -259,7 +259,11 @@ mod tests { let missing_mask = WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable; assert!(missing_mask.source().is_none()); - assert!(missing_mask.to_string().contains("fresh caller-supplied Pong masking key")); + assert!( + missing_mask + .to_string() + .contains("fresh caller-supplied Pong masking key") + ); let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { fin: false, diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index 05f1ce576..006efcb72 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -48,7 +48,10 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } -fn read_masked_client_frame(stream: &mut TcpStream, expected_first_byte: u8) -> io::Result> { +fn read_masked_client_frame( + stream: &mut TcpStream, + expected_first_byte: u8, +) -> io::Result> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; let mut header = [0_u8; 2]; stream.read_exact(&mut header)?; @@ -99,8 +102,8 @@ fn locate_nodes_command() -> Result Result<(), Box> { +fn locate_nodes_exchange_answers_ping_and_ignores_unsolicited_pong_before_response() +-> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<(Vec, Vec)> { @@ -111,14 +114,20 @@ fn locate_nodes_exchange_answers_ping_and_ignores_unsolicited_pong_before_respon )?; let command = read_masked_client_frame(&mut stream, 0x81)?; let ping_length = u8::try_from(PING_PAYLOAD.len()).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidData, "test Ping payload exceeded one-byte length") + io::Error::new( + io::ErrorKind::InvalidData, + "test Ping payload exceeded one-byte length", + ) })?; stream.write_all(&[0x89, ping_length])?; stream.write_all(PING_PAYLOAD)?; let pong = read_masked_client_frame(&mut stream, 0x8a)?; stream.write_all(&[0x8a, 0])?; let response_length = u8::try_from(RESPONSE_DOCUMENT.len()).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidData, "test response exceeded one-byte length") + io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeded one-byte length", + ) })?; stream.write_all(&[0x81, response_length])?; stream.write_all(RESPONSE_DOCUMENT.as_bytes())?; From 4c5e51db0f22c3c366a87d18439db74e54098e28 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:20:24 -0700 Subject: [PATCH 232/570] test(network): require non-generic locateNodes entropy callback --- ...driver_bidi_locate_nodes_coverage_shape.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/test_webdriver_bidi_locate_nodes_coverage_shape.py diff --git a/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py b/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py new file mode 100644 index 000000000..18eec0d6d --- /dev/null +++ b/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py @@ -0,0 +1,42 @@ +"""Coverage-shape contract for the bounded BiDi locateNodes exchange boundary. + +The production coverage gate is exact across functions, lines, regions, and branches. +Keeping the caller-supplied Pong entropy callback generic monomorphizes the whole +exchange function per closure type, which creates synthetic per-instantiation +coverage holes despite exercising the protocol paths. The callback is therefore a +borrowed trait object at this boundary: its behavior remains stateful and caller +owned without multiplying production coverage regions. +""" + +from __future__ import annotations + +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE = ( + ROOT + / "crates" + / "originweave-network" + / "src" + / "webdriver_bidi_locate_nodes_exchange.rs" +) + + +class WebDriverBiDiLocateNodesCoverageShapeTests(unittest.TestCase): + """Prevent callback monomorphization from invalidating exact coverage evidence.""" + + def test_pong_entropy_callback_is_non_generic_at_exchange_boundary(self) -> None: + source = SOURCE.read_text(encoding="utf-8") + self.assertIn( + "next_pong_key: &mut dyn FnMut() -> Option", + source, + ) + self.assertNotIn( + "next_pong_key: impl FnMut() -> Option", + source, + ) + + +if __name__ == "__main__": + unittest.main() From 3801e904ac71d49b0b55557c34092d15677b17f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:04:44 -0700 Subject: [PATCH 233/570] fix(network): erase locateNodes entropy callback type --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index c6b38dc57..0e38d97ff 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -135,7 +135,7 @@ impl WebDriverBiDiWebSocketEstablished { self, command: WebDriverBiDiLocateNodesCommand, command_masking_key: WebDriverBiDiWebSocketMaskKey, - mut next_pong_key: impl FnMut() -> Option, + next_pong_key: &mut dyn FnMut() -> Option, exchange_timeout: Duration, ) -> Result< (Self, ValidatedWebDriverBiDiLocateNodesResult), @@ -156,7 +156,7 @@ impl WebDriverBiDiWebSocketEstablished { match frame.opcode() { 0x9 => { - let masking_key = next_pong_masking_key(&mut next_pong_key)?; + let masking_key = next_pong_masking_key(next_pong_key)?; let remaining_timeout = remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; established = established From 3a9aa8b36c1dae7042302960652dc337f0bd73a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:05:58 -0700 Subject: [PATCH 234/570] test(network): borrow locateNodes entropy callbacks --- .../webdriver_bidi_websocket_locate_nodes_exchange.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index 62e4e95bb..7df73abdf 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -177,7 +177,7 @@ fn exchange_error( let error = established.exchange_locate_nodes( locate_nodes_command(), WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - || None, + &mut || None, frame_timeout, ); assert!(error.is_err(), "{error:?}"); @@ -225,7 +225,7 @@ fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_r let exchanged = established.exchange_locate_nodes( command, WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - || None, + &mut || None, Duration::from_millis(500), ); assert!(exchanged.is_ok(), "{exchanged:?}"); @@ -279,7 +279,7 @@ fn exchange_deadline_is_not_reset_after_the_frame_write() { let error = established.exchange_locate_nodes( locate_nodes_command(), WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - || None, + &mut || None, Duration::from_micros(20), ); assert!(error.is_err(), "{error:?}"); @@ -314,7 +314,7 @@ fn exchange_rejects_a_non_final_or_non_text_response_frame() { let error = established.exchange_locate_nodes( locate_nodes_command(), WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - || None, + &mut || None, Duration::from_millis(500), ); assert!(error.is_err(), "{error:?}"); From 06018f2db4058c6930704a4107b1b07934df8155 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:06:42 -0700 Subject: [PATCH 235/570] test(network): borrow Ping entropy callback --- .../tests/webdriver_bidi_locate_nodes_ping_interleave.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index 006efcb72..333958705 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -145,7 +145,7 @@ fn locate_nodes_exchange_answers_ping_and_ignores_unsolicited_pong_before_respon let exchanged = established.exchange_locate_nodes( command, WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - || pong_keys.next(), + &mut || pong_keys.next(), Duration::from_millis(500), ); From 3b3218aff6f396d23677051bfffe02489a08f43f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:19:39 -0700 Subject: [PATCH 236/570] test(network): close Pong coverage blind spots --- .../src/webdriver_bidi_websocket_control.rs | 99 ++++++++----------- 1 file changed, 41 insertions(+), 58 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs index 31c69a1c3..92da07c5e 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -136,11 +136,12 @@ impl WebDriverBiDiWebSocketEstablished { masking_key: WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { - validate_pong_parameters(payload.len(), frame_timeout)?; - let frame = serialize_pong_frame(payload, masking_key); - let mut now = Instant::now; - write_pong_frame_with_clock(&mut self.stream, &frame, frame_timeout, &mut now)?; - Ok(self) + validate_pong_parameters(payload.len(), frame_timeout).and_then(|()| { + let frame = serialize_pong_frame(payload, masking_key); + let mut now = Instant::now; + write_pong_frame_with_clock(&mut self.stream, &frame, frame_timeout, &mut now) + .map(|()| self) + }) } } @@ -208,21 +209,21 @@ mod tests { #[test] fn pong_parameter_validation_is_fail_closed() { assert!(validate_pong_parameters(0, Duration::from_millis(1)).is_ok()); - assert!(matches!( - validate_pong_parameters(0, Duration::ZERO), - Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) - )); - assert!(matches!( - validate_pong_parameters(0, MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1)), - Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { .. }) - )); - assert!(matches!( - validate_pong_parameters(126, Duration::from_millis(1)), - Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { - payload_bytes: 126, - maximum_bytes: MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES, - }) - )); + + let zero_timeout = validate_pong_parameters(0, Duration::ZERO) + .expect_err("zero timeout must fail closed"); + assert!(format!("{zero_timeout:?}").starts_with("InvalidFrameTimeout")); + + let excessive_timeout = validate_pong_parameters( + 0, + MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1), + ) + .expect_err("timeout above the resource ceiling must fail closed"); + assert!(format!("{excessive_timeout:?}").starts_with("InvalidFrameTimeout")); + + let excessive_payload = validate_pong_parameters(126, Duration::from_millis(1)) + .expect_err("control payload above the RFC 6455 ceiling must fail closed"); + assert!(format!("{excessive_payload:?}").starts_with("FrameTooLarge")); } #[test] @@ -258,55 +259,37 @@ mod tests { let later = start + Duration::from_secs(1); let mut deadline = FakeWriter::new([]); - assert!(matches!( - write_with_fake(&mut deadline, [start, later]), - Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { - bytes_written: 0, - .. - }) - )); + let deadline_error = write_with_fake(&mut deadline, [start, later]) + .expect_err("elapsed deadline must fail closed"); + assert!(format!("{deadline_error:?}").starts_with("FrameWriteTimedOut")); let mut configure = FakeWriter::new([]); configure.timeout_error = Some(io::ErrorKind::PermissionDenied); - assert!(matches!( - write_with_fake(&mut configure, [start, start]), - Err( - WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { - bytes_written: 0, - .. - } - ) - )); + let configure_error = write_with_fake(&mut configure, [start, start]) + .expect_err("write-timeout configuration failure must be preserved"); + assert!( + format!("{configure_error:?}").starts_with("FrameWriteModeConfigurationFailed") + ); let mut zero = FakeWriter::new([WriteAction::Count(0)]); - assert!(matches!( - write_with_fake(&mut zero, [start, start]), - Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written: 0 }) - )); + let zero_error = write_with_fake(&mut zero, [start, start]) + .expect_err("zero-byte progress must fail closed"); + assert!(format!("{zero_error:?}").starts_with("FrameWriteZero")); let mut timed_out = FakeWriter::new([WriteAction::Error(io::ErrorKind::TimedOut)]); - assert!(matches!( - write_with_fake(&mut timed_out, [start, start, later]), - Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { - bytes_written: 0, - .. - }) - )); + let timed_out_error = write_with_fake(&mut timed_out, [start, start, later]) + .expect_err("timed-out write at the deadline must be preserved"); + assert!(format!("{timed_out_error:?}").starts_with("FrameWriteTimedOut")); let mut failed = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - assert!(matches!( - write_with_fake(&mut failed, [start, start]), - Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { - bytes_written: 0, - .. - }) - )); + let failed_error = write_with_fake(&mut failed, [start, start]) + .expect_err("non-retryable write failure must be preserved"); + assert!(format!("{failed_error:?}").starts_with("FrameWriteFailed")); let mut cleanup = FakeWriter::new([WriteAction::Count(6)]); cleanup.cleanup_error = Some(io::ErrorKind::PermissionDenied); - assert!(matches!( - write_with_fake(&mut cleanup, [start, start]), - Err(WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { .. }) - )); + let cleanup_error = write_with_fake(&mut cleanup, [start, start]) + .expect_err("timeout cleanup failure must be preserved"); + assert!(format!("{cleanup_error:?}").starts_with("FrameWriteCleanupFailed")); } } From cd2ca572701f666fcbb29bc104ccfe59dd383379 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:24:32 -0700 Subject: [PATCH 237/570] test(network): accept repeated WebSocket list fields --- ...bdriver_bidi_websocket_repeated_headers.rs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs new file mode 100644 index 000000000..9dd35c80f --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs @@ -0,0 +1,109 @@ +use std::{ + io::{self, Read, Write}, + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketHandshakeResponseError, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RFC6455_SAMPLE_ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; + +fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { + let endpoint = WebDriverBiDiWebSocketEndpoint::new(endpoint).expect("valid loopback endpoint"); + let correlated = endpoint + .correlate_session_id(SESSION_ID) + .expect("matching session id"); + let target = correlated + .into_explicit_connect_target() + .expect("explicit loopback target"); + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("bounded connection plan") + .connect() + .expect("loopback connection") +} + +fn read_opening_request(stream: &mut std::net::TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 256]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "opening request ended before headers", + )); + } + Ok(()) +} + +fn exercise_response(response: Vec) -> Result<(), WebDriverBiDiWebSocketHandshakeResponseError> { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback fixture"); + let local_addr = listener.local_addr().expect("fixture address"); + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(&response)?; + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY).expect("valid RFC key"); + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key) + .expect("opening handshake plan"); + let written = plan + .write_opening_request(Duration::from_millis(500)) + .expect("opening request write"); + let result = written + .read_opening_response(Duration::from_millis(500)) + .map(|established| { + drop(established); + }); + assert!(server.join().expect("fixture thread join").is_ok()); + result +} + +#[test] +fn repeated_list_valued_upgrade_and_connection_lines_are_combined_semantically() { + let response = format!( + "HTTP/1.1 101 Switching Protocols\r\n\ +Upgrade: h2c\r\n\ +Upgrade: websocket\r\n\ +Connection: keep-alive\r\n\ +Connection: Upgrade\r\n\ +Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\r\n" + ) + .into_bytes(); + + let result = exercise_response(response); + assert!(result.is_ok(), "RFC 9110 list-valued fields must combine: {result:?}"); +} + +#[test] +fn repeated_sec_websocket_accept_remains_fail_closed() { + let response = format!( + "HTTP/1.1 101 Switching Protocols\r\n\ +Upgrade: websocket\r\n\ +Connection: Upgrade\r\n\ +Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\ +Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\r\n" + ) + .into_bytes(); + + assert!(matches!( + exercise_response(response), + Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { .. }) + )); +} From 305a40cfc7d5d755a4c766529a5ab9f11f3531e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:04:53 -0700 Subject: [PATCH 238/570] style(network): apply canonical rustfmt --- .../src/webdriver_bidi_websocket_control.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs index 92da07c5e..06274601d 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -210,15 +210,13 @@ mod tests { fn pong_parameter_validation_is_fail_closed() { assert!(validate_pong_parameters(0, Duration::from_millis(1)).is_ok()); - let zero_timeout = validate_pong_parameters(0, Duration::ZERO) - .expect_err("zero timeout must fail closed"); + let zero_timeout = + validate_pong_parameters(0, Duration::ZERO).expect_err("zero timeout must fail closed"); assert!(format!("{zero_timeout:?}").starts_with("InvalidFrameTimeout")); - let excessive_timeout = validate_pong_parameters( - 0, - MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1), - ) - .expect_err("timeout above the resource ceiling must fail closed"); + let excessive_timeout = + validate_pong_parameters(0, MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1)) + .expect_err("timeout above the resource ceiling must fail closed"); assert!(format!("{excessive_timeout:?}").starts_with("InvalidFrameTimeout")); let excessive_payload = validate_pong_parameters(126, Duration::from_millis(1)) @@ -267,9 +265,7 @@ mod tests { configure.timeout_error = Some(io::ErrorKind::PermissionDenied); let configure_error = write_with_fake(&mut configure, [start, start]) .expect_err("write-timeout configuration failure must be preserved"); - assert!( - format!("{configure_error:?}").starts_with("FrameWriteModeConfigurationFailed") - ); + assert!(format!("{configure_error:?}").starts_with("FrameWriteModeConfigurationFailed")); let mut zero = FakeWriter::new([WriteAction::Count(0)]); let zero_error = write_with_fake(&mut zero, [start, start]) From 75147b6a36f4bc33fbf504275354c35b41da7ae5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:13:40 -0700 Subject: [PATCH 239/570] fix(network): combine repeated WebSocket list fields --- .../src/webdriver_bidi_websocket_handshake.rs | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 9aacf0e02..3c826e626 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -581,8 +581,8 @@ fn parse_opening_response( ); } - let mut upgrade = None; - let mut connection = None; + let mut upgrade_has_websocket = false; + let mut connection_has_upgrade = false; let mut accept = None; for line in header_lines.split("\r\n") { if line.is_empty() @@ -618,23 +618,9 @@ fn parse_opening_response( ); } if name.eq_ignore_ascii_case("upgrade") { - if upgrade.is_some() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response repeats the Upgrade header", - }, - ); - } - upgrade = Some(value); + upgrade_has_websocket |= has_header_token(value, "websocket"); } else if name.eq_ignore_ascii_case("connection") { - if connection.is_some() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response repeats the Connection header", - }, - ); - } - connection = Some(value); + connection_has_upgrade |= has_header_token(value, "upgrade"); } else if name.eq_ignore_ascii_case("sec-websocket-accept") { if accept.is_some() { return Err( @@ -647,14 +633,14 @@ fn parse_opening_response( } } - if !upgrade.is_some_and(|value| has_header_token(value, "websocket")) { + if !upgrade_has_websocket { return Err( WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "Upgrade header does not contain websocket", }, ); } - if !connection.is_some_and(|value| has_header_token(value, "upgrade")) { + if !connection_has_upgrade { return Err( WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "Connection header does not contain Upgrade", From 20830e9cfee9947c16c8056f801091891cda44a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:14:05 -0700 Subject: [PATCH 240/570] style(network): apply canonical rustfmt --- .../tests/webdriver_bidi_websocket_repeated_headers.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs index 9dd35c80f..89f1228af 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs @@ -49,7 +49,9 @@ fn read_opening_request(stream: &mut std::net::TcpStream) -> io::Result<()> { Ok(()) } -fn exercise_response(response: Vec) -> Result<(), WebDriverBiDiWebSocketHandshakeResponseError> { +fn exercise_response( + response: Vec, +) -> Result<(), WebDriverBiDiWebSocketHandshakeResponseError> { let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback fixture"); let local_addr = listener.local_addr().expect("fixture address"); let server = thread::spawn(move || -> io::Result<()> { @@ -88,7 +90,10 @@ Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\r\n" .into_bytes(); let result = exercise_response(response); - assert!(result.is_ok(), "RFC 9110 list-valued fields must combine: {result:?}"); + assert!( + result.is_ok(), + "RFC 9110 list-valued fields must combine: {result:?}" + ); } #[test] From edd1107a2884d750afd31f9f37a938baced737dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:18:22 -0700 Subject: [PATCH 241/570] test(network): satisfy strict result handling --- ...bdriver_bidi_websocket_repeated_headers.rs | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs index 89f1228af..a90fe1d19 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_repeated_headers.rs @@ -1,4 +1,5 @@ use std::{ + error::Error, io::{self, Read, Write}, net::TcpListener, thread, @@ -15,18 +16,15 @@ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const RFC6455_SAMPLE_ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; -fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { - let endpoint = WebDriverBiDiWebSocketEndpoint::new(endpoint).expect("valid loopback endpoint"); - let correlated = endpoint - .correlate_session_id(SESSION_ID) - .expect("matching session id"); - let target = correlated - .into_explicit_connect_target() - .expect("explicit loopback target"); - WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) - .expect("bounded connection plan") - .connect() - .expect("loopback connection") +type TestResult = Result>; + +fn connect(endpoint: &str) -> TestResult { + let endpoint = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = endpoint.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + Ok(connection) } fn read_opening_request(stream: &mut std::net::TcpStream) -> io::Result<()> { @@ -51,9 +49,9 @@ fn read_opening_request(stream: &mut std::net::TcpStream) -> io::Result<()> { fn exercise_response( response: Vec, -) -> Result<(), WebDriverBiDiWebSocketHandshakeResponseError> { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback fixture"); - let local_addr = listener.local_addr().expect("fixture address"); +) -> TestResult> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; @@ -62,23 +60,23 @@ fn exercise_response( }); let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); - let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY).expect("valid RFC key"); - let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint), key) - .expect("opening handshake plan"); - let written = plan - .write_opening_request(Duration::from_millis(500)) - .expect("opening request write"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; let result = written .read_opening_response(Duration::from_millis(500)) .map(|established| { drop(established); }); - assert!(server.join().expect("fixture thread join").is_ok()); - result + match server.join() { + Ok(server_result) => server_result?, + Err(_) => return Err(io::Error::other("loopback fixture thread panicked").into()), + } + Ok(result) } #[test] -fn repeated_list_valued_upgrade_and_connection_lines_are_combined_semantically() { +fn repeated_list_valued_upgrade_and_connection_lines_are_combined_semantically() -> TestResult<()> { let response = format!( "HTTP/1.1 101 Switching Protocols\r\n\ Upgrade: h2c\r\n\ @@ -89,15 +87,16 @@ Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\r\n" ) .into_bytes(); - let result = exercise_response(response); + let result = exercise_response(response)?; assert!( result.is_ok(), "RFC 9110 list-valued fields must combine: {result:?}" ); + Ok(()) } #[test] -fn repeated_sec_websocket_accept_remains_fail_closed() { +fn repeated_sec_websocket_accept_remains_fail_closed() -> TestResult<()> { let response = format!( "HTTP/1.1 101 Switching Protocols\r\n\ Upgrade: websocket\r\n\ @@ -108,7 +107,8 @@ Sec-WebSocket-Accept: {RFC6455_SAMPLE_ACCEPT}\r\n\r\n" .into_bytes(); assert!(matches!( - exercise_response(response), + exercise_response(response)?, Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { .. }) )); + Ok(()) } From 3a3a2c5a9ed33031be5a7a6dcc5cd912a28d2f07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:12:38 -0700 Subject: [PATCH 242/570] fix(network): preserve Pong coverage under clippy policy --- .../src/webdriver_bidi_websocket_control.rs | 47 ++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs index 06274601d..aa22579ca 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -210,18 +210,15 @@ mod tests { fn pong_parameter_validation_is_fail_closed() { assert!(validate_pong_parameters(0, Duration::from_millis(1)).is_ok()); - let zero_timeout = - validate_pong_parameters(0, Duration::ZERO).expect_err("zero timeout must fail closed"); - assert!(format!("{zero_timeout:?}").starts_with("InvalidFrameTimeout")); + let zero_timeout = validate_pong_parameters(0, Duration::ZERO); + assert!(format!("{zero_timeout:?}").starts_with("Err(InvalidFrameTimeout")); let excessive_timeout = - validate_pong_parameters(0, MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1)) - .expect_err("timeout above the resource ceiling must fail closed"); - assert!(format!("{excessive_timeout:?}").starts_with("InvalidFrameTimeout")); + validate_pong_parameters(0, MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1)); + assert!(format!("{excessive_timeout:?}").starts_with("Err(InvalidFrameTimeout")); - let excessive_payload = validate_pong_parameters(126, Duration::from_millis(1)) - .expect_err("control payload above the RFC 6455 ceiling must fail closed"); - assert!(format!("{excessive_payload:?}").starts_with("FrameTooLarge")); + let excessive_payload = validate_pong_parameters(126, Duration::from_millis(1)); + assert!(format!("{excessive_payload:?}").starts_with("Err(FrameTooLarge")); } #[test] @@ -257,35 +254,31 @@ mod tests { let later = start + Duration::from_secs(1); let mut deadline = FakeWriter::new([]); - let deadline_error = write_with_fake(&mut deadline, [start, later]) - .expect_err("elapsed deadline must fail closed"); - assert!(format!("{deadline_error:?}").starts_with("FrameWriteTimedOut")); + let deadline_error = write_with_fake(&mut deadline, [start, later]); + assert!(format!("{deadline_error:?}").starts_with("Err(FrameWriteTimedOut")); let mut configure = FakeWriter::new([]); configure.timeout_error = Some(io::ErrorKind::PermissionDenied); - let configure_error = write_with_fake(&mut configure, [start, start]) - .expect_err("write-timeout configuration failure must be preserved"); - assert!(format!("{configure_error:?}").starts_with("FrameWriteModeConfigurationFailed")); + let configure_error = write_with_fake(&mut configure, [start, start]); + assert!( + format!("{configure_error:?}").starts_with("Err(FrameWriteModeConfigurationFailed") + ); let mut zero = FakeWriter::new([WriteAction::Count(0)]); - let zero_error = write_with_fake(&mut zero, [start, start]) - .expect_err("zero-byte progress must fail closed"); - assert!(format!("{zero_error:?}").starts_with("FrameWriteZero")); + let zero_error = write_with_fake(&mut zero, [start, start]); + assert!(format!("{zero_error:?}").starts_with("Err(FrameWriteZero")); let mut timed_out = FakeWriter::new([WriteAction::Error(io::ErrorKind::TimedOut)]); - let timed_out_error = write_with_fake(&mut timed_out, [start, start, later]) - .expect_err("timed-out write at the deadline must be preserved"); - assert!(format!("{timed_out_error:?}").starts_with("FrameWriteTimedOut")); + let timed_out_error = write_with_fake(&mut timed_out, [start, start, later]); + assert!(format!("{timed_out_error:?}").starts_with("Err(FrameWriteTimedOut")); let mut failed = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - let failed_error = write_with_fake(&mut failed, [start, start]) - .expect_err("non-retryable write failure must be preserved"); - assert!(format!("{failed_error:?}").starts_with("FrameWriteFailed")); + let failed_error = write_with_fake(&mut failed, [start, start]); + assert!(format!("{failed_error:?}").starts_with("Err(FrameWriteFailed")); let mut cleanup = FakeWriter::new([WriteAction::Count(6)]); cleanup.cleanup_error = Some(io::ErrorKind::PermissionDenied); - let cleanup_error = write_with_fake(&mut cleanup, [start, start]) - .expect_err("timeout cleanup failure must be preserved"); - assert!(format!("{cleanup_error:?}").starts_with("FrameWriteCleanupFailed")); + let cleanup_error = write_with_fake(&mut cleanup, [start, start]); + assert!(format!("{cleanup_error:?}").starts_with("Err(FrameWriteCleanupFailed")); } } From 946971ecd29070779cbca7a5b59379fd9ef8384e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:38:33 -0700 Subject: [PATCH 243/570] test(network): cover Ping failure exits --- .../webdriver_bidi_locate_nodes_exchange.rs | 217 +++++++++++++++++- 1 file changed, 209 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 0e38d97ff..00298e7c6 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -188,18 +188,147 @@ impl WebDriverBiDiWebSocketEstablished { #[cfg(test)] mod tests { - use std::{error::Error as _, time::Duration}; + use std::{ + error::Error as _, + io::{self, Read, Write}, + net::{Shutdown, TcpListener, TcpStream}, + thread, + time::Duration, + }; use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, + WebDriverBiDiWebSocketEndpoint, }; - use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; + use crate::{ + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + }; use super::{ - WebDriverBiDiLocateNodesExchangeError, next_pong_masking_key, remaining_exchange_budget, + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiWebSocketEstablished, + next_pong_masking_key, remaining_exchange_budget, }; + const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + const PING_PAYLOAD: &[u8] = b"x"; + + fn connect( + endpoint: &str, + ) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) + } + + fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) + } + + fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client command was not one final masked text frame", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + usize::try_from(u64::from_be_bytes(extended)).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "client command length overflowed") + })? + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) + } + + fn locate_nodes_command( + ) -> Result> { + let query = + WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) + } + + fn establish_with_ping( + keep_open: Duration, + ) -> Result< + ( + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, + ), + Box, + > { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_text_frame(&mut stream)?; + stream.write_all(&[0x89, PING_PAYLOAD.len() as u8])?; + stream.write_all(PING_PAYLOAD)?; + thread::sleep(keep_open); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) + } + + fn join_server( + server: thread::JoinHandle>, + ) -> Result<(), Box> { + let result = server + .join() + .map_err(|_| io::Error::other("Ping failure test server panicked"))?; + Ok(result?) + } + #[test] fn exchange_budget_consumes_elapsed_time_instead_of_resetting() { let total = Duration::from_millis(500); @@ -225,15 +354,87 @@ mod tests { #[test] fn pong_masking_key_source_fails_closed_when_entropy_is_unavailable() { - let expected = crate::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let expected = WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); let mut available = || Some(expected); assert_eq!(next_pong_masking_key(&mut available).ok(), Some(expected)); let mut unavailable = || None; - assert!(matches!( - next_pong_masking_key(&mut unavailable), - Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) - )); + let unavailable_error = next_pong_masking_key(&mut unavailable); + assert_eq!( + format!("{unavailable_error:?}"), + "Err(PongMaskingKeyUnavailable)" + ); + } + + #[test] + fn ping_exchange_fails_closed_when_pong_entropy_is_unavailable( + ) -> Result<(), Box> { + let (established, server) = establish_with_ping(Duration::from_millis(100))?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_secs(1), + ); + let error = exchanged.err().ok_or_else(|| { + io::Error::other("Ping without caller entropy unexpectedly succeeded") + })?; + assert_eq!( + error.to_string(), + "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key" + ); + join_server(server) + } + + #[test] + fn ping_exchange_charges_entropy_callback_time_to_the_end_to_end_deadline( + ) -> Result<(), Box> { + let (established, server) = establish_with_ping(Duration::from_millis(650))?; + let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || { + thread::sleep(Duration::from_millis(550)); + Some(pong_key) + }, + Duration::from_millis(500), + ); + let error = exchanged.err().ok_or_else(|| { + io::Error::other("slow Pong entropy callback unexpectedly reset the exchange deadline") + })?; + assert_eq!( + error.to_string(), + "WebDriver BiDi locateNodes exchange exhausted its 500ms end-to-end deadline before the next operation" + ); + join_server(server) + } + + #[test] + fn ping_exchange_preserves_pong_write_failure_as_the_first_causal_error( + ) -> Result<(), Box> { + let (established, server) = establish_with_ping(Duration::from_millis(100))?; + let shutdown_stream = established.stream.try_clone()?; + let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || { + assert!(shutdown_stream.shutdown(Shutdown::Both).is_ok()); + Some(pong_key) + }, + Duration::from_secs(1), + ); + let error = exchanged + .err() + .ok_or_else(|| io::Error::other("revoked Pong stream unexpectedly remained usable"))?; + assert!( + error + .to_string() + .starts_with("WebDriver BiDi locateNodes WebSocket frame exchange failed:") + ); + assert!(error.source().is_some()); + join_server(server) } #[test] From 66eb0193ff97873bd8002fadad6d8a6465aa2022 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:47:21 -0700 Subject: [PATCH 244/570] test(network): keep transport helpers outside production coverage --- .../webdriver_bidi_locate_nodes_exchange.rs | 217 +----------------- 1 file changed, 8 insertions(+), 209 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 00298e7c6..0e38d97ff 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -188,147 +188,18 @@ impl WebDriverBiDiWebSocketEstablished { #[cfg(test)] mod tests { - use std::{ - error::Error as _, - io::{self, Read, Write}, - net::{Shutdown, TcpListener, TcpStream}, - thread, - time::Duration, - }; + use std::{error::Error as _, time::Duration}; use originweave_core::{ - WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, - WebDriverBiDiWebSocketEndpoint, }; - use crate::{ - MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketFrameError, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, - }; + use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; use super::{ - WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiWebSocketEstablished, - next_pong_masking_key, remaining_exchange_budget, + WebDriverBiDiLocateNodesExchangeError, next_pong_masking_key, remaining_exchange_budget, }; - const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; - const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; - const PING_PAYLOAD: &[u8] = b"x"; - - fn connect( - endpoint: &str, - ) -> Result> { - let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; - let correlated = admitted.correlate_session_id(SESSION_ID)?; - let target = correlated.into_explicit_connect_target()?; - let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; - Ok(plan.connect()?) - } - - fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { - stream.set_read_timeout(Some(Duration::from_secs(2)))?; - let mut request = Vec::new(); - let mut buffer = [0_u8; 512]; - while !request.ends_with(b"\r\n\r\n") { - let count = stream.read(&mut buffer)?; - if count == 0 { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "client opening request ended before the header terminator", - )); - } - request.extend_from_slice(&buffer[..count]); - } - Ok(()) - } - - fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { - stream.set_read_timeout(Some(Duration::from_secs(2)))?; - let mut header = [0_u8; 2]; - stream.read_exact(&mut header)?; - if header[0] != 0x81 || header[1] & 0x80 == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "client command was not one final masked text frame", - )); - } - let payload_length = match header[1] & 0x7f { - value @ 0..=125 => usize::from(value), - 126 => { - let mut extended = [0_u8; 2]; - stream.read_exact(&mut extended)?; - usize::from(u16::from_be_bytes(extended)) - } - 127 => { - let mut extended = [0_u8; 8]; - stream.read_exact(&mut extended)?; - usize::try_from(u64::from_be_bytes(extended)).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidData, "client command length overflowed") - })? - } - _ => unreachable!("7-bit WebSocket payload marker"), - }; - let mut mask = [0_u8; 4]; - stream.read_exact(&mut mask)?; - let mut payload = vec![0_u8; payload_length]; - stream.read_exact(&mut payload)?; - Ok(()) - } - - fn locate_nodes_command( - ) -> Result> { - let query = - WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; - Ok(WebDriverBiDiLocateNodesCommand::new( - 7, - "top-level-context", - &query, - )?) - } - - fn establish_with_ping( - keep_open: Duration, - ) -> Result< - ( - WebDriverBiDiWebSocketEstablished, - thread::JoinHandle>, - ), - Box, - > { - let listener = TcpListener::bind(("127.0.0.1", 0))?; - let local_addr = listener.local_addr()?; - let server = thread::spawn(move || -> io::Result<()> { - let (mut stream, _) = listener.accept()?; - read_opening_request(&mut stream)?; - stream.write_all( - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", - )?; - read_masked_client_text_frame(&mut stream)?; - stream.write_all(&[0x89, PING_PAYLOAD.len() as u8])?; - stream.write_all(PING_PAYLOAD)?; - thread::sleep(keep_open); - Ok(()) - }); - - let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); - let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; - let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; - let written = plan.write_opening_request(Duration::from_millis(500))?; - let established = written.read_opening_response(Duration::from_millis(500))?; - Ok((established, server)) - } - - fn join_server( - server: thread::JoinHandle>, - ) -> Result<(), Box> { - let result = server - .join() - .map_err(|_| io::Error::other("Ping failure test server panicked"))?; - Ok(result?) - } - #[test] fn exchange_budget_consumes_elapsed_time_instead_of_resetting() { let total = Duration::from_millis(500); @@ -354,87 +225,15 @@ mod tests { #[test] fn pong_masking_key_source_fails_closed_when_entropy_is_unavailable() { - let expected = WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let expected = crate::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); let mut available = || Some(expected); assert_eq!(next_pong_masking_key(&mut available).ok(), Some(expected)); let mut unavailable = || None; - let unavailable_error = next_pong_masking_key(&mut unavailable); - assert_eq!( - format!("{unavailable_error:?}"), - "Err(PongMaskingKeyUnavailable)" - ); - } - - #[test] - fn ping_exchange_fails_closed_when_pong_entropy_is_unavailable( - ) -> Result<(), Box> { - let (established, server) = establish_with_ping(Duration::from_millis(100))?; - let exchanged = established.exchange_locate_nodes( - locate_nodes_command()?, - WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - &mut || None, - Duration::from_secs(1), - ); - let error = exchanged.err().ok_or_else(|| { - io::Error::other("Ping without caller entropy unexpectedly succeeded") - })?; - assert_eq!( - error.to_string(), - "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key" - ); - join_server(server) - } - - #[test] - fn ping_exchange_charges_entropy_callback_time_to_the_end_to_end_deadline( - ) -> Result<(), Box> { - let (established, server) = establish_with_ping(Duration::from_millis(650))?; - let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); - let exchanged = established.exchange_locate_nodes( - locate_nodes_command()?, - WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - &mut || { - thread::sleep(Duration::from_millis(550)); - Some(pong_key) - }, - Duration::from_millis(500), - ); - let error = exchanged.err().ok_or_else(|| { - io::Error::other("slow Pong entropy callback unexpectedly reset the exchange deadline") - })?; - assert_eq!( - error.to_string(), - "WebDriver BiDi locateNodes exchange exhausted its 500ms end-to-end deadline before the next operation" - ); - join_server(server) - } - - #[test] - fn ping_exchange_preserves_pong_write_failure_as_the_first_causal_error( - ) -> Result<(), Box> { - let (established, server) = establish_with_ping(Duration::from_millis(100))?; - let shutdown_stream = established.stream.try_clone()?; - let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); - let exchanged = established.exchange_locate_nodes( - locate_nodes_command()?, - WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - &mut || { - assert!(shutdown_stream.shutdown(Shutdown::Both).is_ok()); - Some(pong_key) - }, - Duration::from_secs(1), - ); - let error = exchanged - .err() - .ok_or_else(|| io::Error::other("revoked Pong stream unexpectedly remained usable"))?; - assert!( - error - .to_string() - .starts_with("WebDriver BiDi locateNodes WebSocket frame exchange failed:") - ); - assert!(error.source().is_some()); - join_server(server) + assert!(matches!( + next_pong_masking_key(&mut unavailable), + Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) + )); } #[test] From a507fed5c3d838fc409bfcb94c8c54d6819ba573 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 11:48:05 -0700 Subject: [PATCH 245/570] test(network): exercise Ping failure boundaries externally --- ...river_bidi_locate_nodes_ping_interleave.rs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index 333958705..656dea145 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -101,6 +101,51 @@ fn locate_nodes_command() -> Result Result< + ( + originweave_network::WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, + ), + Box, +> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let _command = read_masked_client_frame(&mut stream, 0x81)?; + let ping_length = u8::try_from(PING_PAYLOAD.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test Ping payload exceeded one-byte length", + ) + })?; + stream.write_all(&[0x89, ping_length])?; + stream.write_all(PING_PAYLOAD)?; + thread::sleep(keep_open); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +fn join_ping_server(server: thread::JoinHandle>) -> Result<(), Box> { + let result = server + .join() + .map_err(|_| io::Error::other("Ping failure test server panicked"))?; + Ok(result?) +} + #[test] fn locate_nodes_exchange_answers_ping_and_ignores_unsolicited_pong_before_response() -> Result<(), Box> { @@ -171,3 +216,49 @@ fn locate_nodes_exchange_answers_ping_and_ignores_unsolicited_pong_before_respon ); Ok(()) } + +#[test] +fn locate_nodes_exchange_fails_closed_when_ping_entropy_is_unavailable() +-> Result<(), Box> { + let (established, server) = establish_with_ping(Duration::from_millis(100))?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + ); + + let error = exchanged + .err() + .ok_or_else(|| io::Error::other("Ping without masking entropy unexpectedly succeeded"))?; + assert_eq!( + error.to_string(), + "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key" + ); + join_ping_server(server) +} + +#[test] +fn locate_nodes_exchange_charges_ping_callback_time_to_exchange_deadline() +-> Result<(), Box> { + let (established, server) = establish_with_ping(Duration::from_millis(650))?; + let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || { + thread::sleep(Duration::from_millis(550)); + Some(pong_key) + }, + Duration::from_millis(500), + ); + + let error = exchanged + .err() + .ok_or_else(|| io::Error::other("slow Ping callback unexpectedly reset the exchange deadline"))?; + assert_eq!( + error.to_string(), + "WebDriver BiDi locateNodes exchange exhausted its 500ms end-to-end deadline before the next operation" + ); + join_ping_server(server) +} From faffcab0047ba7402e52316b6ffc4fc02ef53afa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 12:06:53 -0700 Subject: [PATCH 246/570] test(network): format BiDi ping deadline regression --- .../tests/webdriver_bidi_locate_nodes_ping_interleave.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index 656dea145..7a53d2c78 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -253,9 +253,9 @@ fn locate_nodes_exchange_charges_ping_callback_time_to_exchange_deadline() Duration::from_millis(500), ); - let error = exchanged - .err() - .ok_or_else(|| io::Error::other("slow Ping callback unexpectedly reset the exchange deadline"))?; + let error = exchanged.err().ok_or_else(|| { + io::Error::other("slow Ping callback unexpectedly reset the exchange deadline") + })?; assert_eq!( error.to_string(), "WebDriver BiDi locateNodes exchange exhausted its 500ms end-to-end deadline before the next operation" From 60385f419209a98ddb1f0ab8719b4478bf8d160c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 12:09:24 -0700 Subject: [PATCH 247/570] test(network): factor BiDi ping fixture type --- ...webdriver_bidi_locate_nodes_ping_interleave.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index 7a53d2c78..1c69aec40 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -21,6 +21,11 @@ const RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; const PING_PAYLOAD: &[u8] = b"keepalive"; +type EstablishedPingServer = ( + originweave_network::WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + fn connect( endpoint: &str, ) -> Result> { @@ -101,15 +106,7 @@ fn locate_nodes_command() -> Result Result< - ( - originweave_network::WebDriverBiDiWebSocketEstablished, - thread::JoinHandle>, - ), - Box, -> { +fn establish_with_ping(keep_open: Duration) -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { From 0bad232930ab0ec0f9b0b17bb186a50c7295741b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 12:10:33 -0700 Subject: [PATCH 248/570] test(network): close exact BiDi exchange coverage gaps --- .../webdriver_bidi_locate_nodes_exchange.rs | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 0e38d97ff..dcfed72b9 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -107,6 +107,12 @@ fn next_pong_masking_key( next_key().ok_or(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) } +fn map_established_frame_result( + result: Result, +) -> Result { + result.map_err(WebDriverBiDiLocateNodesExchangeError::Frame) +} + impl WebDriverBiDiWebSocketEstablished { /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. /// @@ -142,9 +148,11 @@ impl WebDriverBiDiWebSocketEstablished { WebDriverBiDiLocateNodesExchangeError, > { let started_at = Instant::now(); - let mut established = self - .write_text_frame(command.as_json(), command_masking_key, exchange_timeout) - .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + let mut established = map_established_frame_result(self.write_text_frame( + command.as_json(), + command_masking_key, + exchange_timeout, + ))?; loop { let remaining_timeout = @@ -159,9 +167,11 @@ impl WebDriverBiDiWebSocketEstablished { let masking_key = next_pong_masking_key(next_pong_key)?; let remaining_timeout = remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; - established = established - .write_pong_frame(frame.payload(), masking_key, remaining_timeout) - .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + established = map_established_frame_result(established.write_pong_frame( + frame.payload(), + masking_key, + remaining_timeout, + ))?; } 0xa => {} 0x1 if frame.fin() => { @@ -230,10 +240,10 @@ mod tests { assert_eq!(next_pong_masking_key(&mut available).ok(), Some(expected)); let mut unavailable = || None; - assert!(matches!( - next_pong_masking_key(&mut unavailable), - Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) - )); + assert_eq!( + format!("{:?}", next_pong_masking_key(&mut unavailable)), + "Err(PongMaskingKeyUnavailable)" + ); } #[test] From 7d7c7d8e6dd25d88418f0c86998b51a5b7f34f31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:09:50 -0700 Subject: [PATCH 249/570] test(network): cover locateNodes Pong write failure --- ..._nodes_exchange_transport_failure_tests.rs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs new file mode 100644 index 000000000..faccb4420 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -0,0 +1,153 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{Shutdown, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; + +use crate::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnection, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn connect(endpoint: &str) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client command was not one final masked text frame", + )); + } + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => u64::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + u64::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + u64::from_be_bytes(extended) + } + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut remaining = usize::try_from(payload_length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client command length cannot fit this test process", + ) + })?; + let mut buffer = [0_u8; 512]; + while remaining != 0 { + let chunk = remaining.min(buffer.len()); + stream.read_exact(&mut buffer[..chunk])?; + remaining -= chunk; + } + Ok(()) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +#[test] +fn locate_nodes_exchange_preserves_pong_write_failure_after_ping() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_text_frame(&mut stream)?; + stream.write_all(&[0x89, 0])?; + thread::sleep(Duration::from_millis(250)); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let shutdown_stream = established.stream.try_clone()?; + let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || { + let shutdown = shutdown_stream.shutdown(Shutdown::Write); + assert!(shutdown.is_ok(), "{shutdown:?}"); + Some(pong_key) + }, + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("Pong write failure test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("locateNodes exchange unexpectedly survived a closed client write half") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::FrameWriteFailed { .. } + ) + ), + "{error:?}" + ); + assert!(error.source().is_some()); + Ok(()) +} From ad6659be2e57307225883c37866399939fa18f05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:10:31 -0700 Subject: [PATCH 250/570] test(network): wire Pong write failure regression --- crates/originweave-network/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index a09dae0d3..f84d440c3 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -17,6 +17,8 @@ mod connection; mod webdriver_bidi_connection; mod webdriver_bidi_locate_nodes_exchange; +#[cfg(test)] +mod webdriver_bidi_locate_nodes_exchange_transport_failure_tests; mod webdriver_bidi_websocket_control; mod webdriver_bidi_websocket_handshake; From f48f4dd9a61324e3c7bc575b33a1a955231919cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:14:32 -0700 Subject: [PATCH 251/570] test(network): make client frame parser exhaustive --- ...driver_bidi_locate_nodes_exchange_transport_failure_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs index faccb4420..14c5cad2a 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -64,7 +64,7 @@ fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { stream.read_exact(&mut extended)?; u64::from(u16::from_be_bytes(extended)) } - 127 => { + _ => { let mut extended = [0_u8; 8]; stream.read_exact(&mut extended)?; u64::from_be_bytes(extended) From c6daa6da49c5574b6d76f41198f8c4ca9dc939e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:28:25 -0700 Subject: [PATCH 252/570] test(network): cover public locateNodes error sources --- ...di_locate_nodes_exchange_error_contract.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_error_contract.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_error_contract.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_error_contract.rs new file mode 100644 index 000000000..f2ed6acfd --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_error_contract.rs @@ -0,0 +1,44 @@ +use std::{error::Error as _, time::Duration}; + +use originweave_core::{ + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, +}; +use originweave_network::{ + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiLocateNodesExchangeError, + WebDriverBiDiWebSocketFrameError, +}; + +#[test] +fn downstream_callers_observe_exact_exchange_error_sources() { + let frame = WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + ); + assert!(frame.source().is_some()); + + let document = WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8, + ); + assert!(document.source().is_some()); + + let response = WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + ); + assert!(response.source().is_some()); + + let source_free_errors = [ + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout: Duration::from_millis(500), + }, + WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable, + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: false, + opcode: 0x2, + }, + ]; + for error in source_free_errors { + assert!(error.source().is_none(), "{error:?}"); + } +} From 623f1cbdac5b8881e1142a092619ad3b7e45a703 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 14:20:25 -0700 Subject: [PATCH 253/570] test(network): cover locateNodes Pong write failure downstream --- ...er_bidi_locate_nodes_pong_write_failure.rs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_pong_write_failure.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_pong_write_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_pong_write_failure.rs new file mode 100644 index 000000000..ec5f8a915 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_pong_write_failure.rs @@ -0,0 +1,153 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{Shutdown, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnection, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn connect(endpoint: &str) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client command was not one final masked text frame", + )); + } + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => u64::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + u64::from(u16::from_be_bytes(extended)) + } + _ => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + u64::from_be_bytes(extended) + } + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut remaining = usize::try_from(payload_length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client command length cannot fit this test process", + ) + })?; + let mut buffer = [0_u8; 512]; + while remaining != 0 { + let chunk = remaining.min(buffer.len()); + stream.read_exact(&mut buffer[..chunk])?; + remaining -= chunk; + } + Ok(()) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +#[test] +fn locate_nodes_exchange_preserves_pong_write_failure_after_ping() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_text_frame(&mut stream)?; + stream.write_all(&[0x89, 0])?; + thread::sleep(Duration::from_millis(250)); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let connection = connect(&endpoint)?; + let shutdown_stream = connection.stream().try_clone()?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || { + let shutdown = shutdown_stream.shutdown(Shutdown::Write); + assert!(shutdown.is_ok(), "{shutdown:?}"); + Some(pong_key) + }, + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("Pong write failure test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("locateNodes exchange unexpectedly survived a closed client write half") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::FrameWriteFailed { .. } + ) + ), + "{error:?}" + ); + assert!(error.source().is_some()); + Ok(()) +} From fd34bb5e928210520715a90a4a247531890723fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:06:15 -0700 Subject: [PATCH 254/570] test(evidence): require extraction schema standard errors --- .../tests/extraction_schema_error_contract.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 crates/originweave-evidence/tests/extraction_schema_error_contract.rs diff --git a/crates/originweave-evidence/tests/extraction_schema_error_contract.rs b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs new file mode 100644 index 000000000..40cc6d79b --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs @@ -0,0 +1,44 @@ +use std::error::Error as _; + +use originweave_evidence::ExtractionSchemaError; + +fn assert_standard_error_contract() {} + +#[test] +fn extraction_schema_errors_implement_standard_error_contract() { + assert_standard_error_contract::(); + + for (error, message) in [ + ( + ExtractionSchemaError::InvalidIdentifier, + "invalid extraction schema identifier", + ), + ( + ExtractionSchemaError::LimitExceeded, + "extraction schema limit exceeded", + ), + ( + ExtractionSchemaError::MissingSourceChannel, + "extraction field requires at least one source channel", + ), + ( + ExtractionSchemaError::DuplicateSourceChannel, + "extraction field contains a duplicate source channel", + ), + ( + ExtractionSchemaError::InvalidNormalizationRule, + "extraction normalization rule is incompatible with the field value type", + ), + ( + ExtractionSchemaError::MissingField, + "extraction schema requires at least one field", + ), + ( + ExtractionSchemaError::DuplicateField, + "extraction schema contains a duplicate field identifier", + ), + ] { + assert_eq!(error.to_string(), message); + assert!(error.source().is_none()); + } +} From ef02ee8607b52b6a0955240f959f95d8e9b3df60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:07:58 -0700 Subject: [PATCH 255/570] fix(evidence): expose extraction schema standard errors --- .../src/extraction_schema.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/originweave-evidence/src/extraction_schema.rs b/crates/originweave-evidence/src/extraction_schema.rs index d8f978e74..abffc4679 100644 --- a/crates/originweave-evidence/src/extraction_schema.rs +++ b/crates/originweave-evidence/src/extraction_schema.rs @@ -5,7 +5,7 @@ //! disclose protected values, persist artifacts, execute models, or grant any //! browser, network, secret, approval, or storage authority. -use std::collections::BTreeSet; +use std::{collections::BTreeSet, fmt}; /// Maximum encoded byte length for an extraction schema or field identifier. pub const MAX_EXTRACTION_IDENTIFIER_BYTES: usize = 128; @@ -83,6 +83,24 @@ pub enum ExtractionSchemaError { DuplicateField, } +impl fmt::Display for ExtractionSchemaError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidIdentifier => "invalid extraction schema identifier", + Self::LimitExceeded => "extraction schema limit exceeded", + Self::MissingSourceChannel => "extraction field requires at least one source channel", + Self::DuplicateSourceChannel => "extraction field contains a duplicate source channel", + Self::InvalidNormalizationRule => { + "extraction normalization rule is incompatible with the field value type" + } + Self::MissingField => "extraction schema requires at least one field", + Self::DuplicateField => "extraction schema contains a duplicate field identifier", + }) + } +} + +impl std::error::Error for ExtractionSchemaError {} + /// One typed field declared by a versioned extraction schema. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExtractionField { From 40988c3caaeb6a962d971e965e008bdea04c2c37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:10:59 -0700 Subject: [PATCH 256/570] docs(changelog): record extraction schema error contract --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a9a59e77..0136ba881 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Real loopback rustls integration covering trusted DNS SAN, Common-Name fallback rejection, wrong-name and untrusted-root rejection, fixed-time expiry and not-yet-valid failures, exact IPv4 and IPv6 SANs, TLS 1.2/TLS 1.3, required and optional ALPN, and transport-origin binding. - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. -- Versioned schema-bound extraction contracts with bounded identifiers and field counts, typed value/cardinality metadata, explicit duplicate-free reviewed source channels, and fail-closed schema validation. +- Versioned schema-bound extraction contracts with bounded identifiers and field counts, typed value/cardinality metadata, explicit duplicate-free reviewed source channels, fail-closed schema validation, and deterministic `Display`/`std::error::Error` contracts for public schema failures. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. From 2fdc3802b058d53d43c5c6f0f3f1361eb1acb4b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:49:40 -0700 Subject: [PATCH 257/570] test(evidence): make identifier error contract scope-neutral --- .../tests/extraction_schema_error_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-evidence/tests/extraction_schema_error_contract.rs b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs index 40cc6d79b..1ba248a1d 100644 --- a/crates/originweave-evidence/tests/extraction_schema_error_contract.rs +++ b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs @@ -11,7 +11,7 @@ fn extraction_schema_errors_implement_standard_error_contract() { for (error, message) in [ ( ExtractionSchemaError::InvalidIdentifier, - "invalid extraction schema identifier", + "invalid extraction schema or field identifier", ), ( ExtractionSchemaError::LimitExceeded, From 4b1164c01f0b8a888c6b3dc9bc32ad65184ec171 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:49:55 -0700 Subject: [PATCH 258/570] test(evidence): require canonical source-channel set identity --- .../tests/extraction_source_channel_set.rs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 crates/originweave-evidence/tests/extraction_source_channel_set.rs diff --git a/crates/originweave-evidence/tests/extraction_source_channel_set.rs b/crates/originweave-evidence/tests/extraction_source_channel_set.rs new file mode 100644 index 000000000..1f5070e8a --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_source_channel_set.rs @@ -0,0 +1,40 @@ +#![allow(clippy::expect_used)] + +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionSourceChannel, ExtractionValueType, +}; + +#[test] +fn equivalent_source_channel_sets_have_canonical_identity() { + let semantic_then_network = ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::NetworkResponse, + ], + ) + .expect("reviewed source set must be valid"); + let network_then_semantic = ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::NetworkResponse, + ExtractionSourceChannel::SemanticNode, + ], + ) + .expect("equivalent reviewed source set must be valid"); + + assert_eq!(semantic_then_network, network_then_semantic); + assert_eq!( + network_then_semantic.source_channels(), + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::NetworkResponse, + ] + ); +} From f483af9829dd064e6d9ae17411fa2af9963d4cc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:51:47 -0700 Subject: [PATCH 259/570] fix(evidence): canonicalize extraction source-channel sets --- crates/originweave-evidence/src/extraction_schema.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-evidence/src/extraction_schema.rs b/crates/originweave-evidence/src/extraction_schema.rs index abffc4679..ca2ba4881 100644 --- a/crates/originweave-evidence/src/extraction_schema.rs +++ b/crates/originweave-evidence/src/extraction_schema.rs @@ -86,7 +86,7 @@ pub enum ExtractionSchemaError { impl fmt::Display for ExtractionSchemaError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { - Self::InvalidIdentifier => "invalid extraction schema identifier", + Self::InvalidIdentifier => "invalid extraction schema or field identifier", Self::LimitExceeded => "extraction schema limit exceeded", Self::MissingSourceChannel => "extraction field requires at least one source channel", Self::DuplicateSourceChannel => "extraction field contains a duplicate source channel", @@ -169,7 +169,7 @@ impl ExtractionField { cardinality, required, normalization_rule, - source_channels: source_channels.to_vec(), + source_channels: seen_channels.into_iter().collect(), }) } From b84bebb7d902d3dd4204f4e69c5b133e439fdddb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:10:05 -0700 Subject: [PATCH 260/570] test(network): bound locateNodes control-frame interleave --- ...river_bidi_locate_nodes_ping_interleave.rs | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index 1c69aec40..59d77b4a5 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -11,8 +11,9 @@ use originweave_core::{ WebDriverBiDiWebSocketEndpoint, }; use originweave_network::{ - WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -259,3 +260,60 @@ fn locate_nodes_exchange_charges_ping_callback_time_to_exchange_deadline() ); join_ping_server(server) } + +#[test] +fn locate_nodes_exchange_bounds_valid_interleaved_control_frames() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let _command = read_masked_client_frame(&mut stream, 0x81)?; + + let mut frames = Vec::with_capacity( + (MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1) * 2 + RESPONSE_DOCUMENT.len() + 2, + ); + for _ in 0..=MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE { + frames.extend_from_slice(&[0x8a, 0]); + } + let response_length = u8::try_from(RESPONSE_DOCUMENT.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeded one-byte length", + ) + })?; + frames.extend_from_slice(&[0x81, response_length]); + frames.extend_from_slice(RESPONSE_DOCUMENT.as_bytes()); + stream.write_all(&frames) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("control-frame limit test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + let error = exchanged + .err() + .ok_or_else(|| io::Error::other("control-frame flood unexpectedly reached response"))?; + assert!(matches!( + error, + WebDriverBiDiLocateNodesExchangeError::ControlFrameLimitExceeded { + maximum_control_frames, + } if maximum_control_frames == MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + )); + Ok(()) +} From d572b598766a63103310b227a51a7f5e22cfcfaa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:12:18 -0700 Subject: [PATCH 261/570] test(network): align control-frame RED formatting --- .../tests/webdriver_bidi_locate_nodes_ping_interleave.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs index 59d77b4a5..3e26f30b7 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_ping_interleave.rs @@ -11,9 +11,9 @@ use originweave_core::{ WebDriverBiDiWebSocketEndpoint, }; use originweave_network::{ - WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMaskKey, MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; From 6cc3fb5dbb4ee6cc713b0b74605b9213c13264c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:14:26 -0700 Subject: [PATCH 262/570] fix(network): bound locateNodes control-frame interleave --- .../webdriver_bidi_locate_nodes_exchange.rs | 61 ++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index dcfed72b9..5bb944080 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -15,13 +15,21 @@ use crate::webdriver_bidi_websocket_handshake::{ WebDriverBiDiWebSocketMaskKey, }; +/// Maximum number of valid RFC 6455 Ping/Pong control frames one `locateNodes` exchange will process. +/// +/// RFC 6455 permits control frames to be interleaved with data frames; this OriginWeave-owned +/// resource budget prevents a peer from turning that permission into an unbounded control-frame loop +/// before the correlated BiDi response arrives. The end-to-end exchange deadline remains an +/// independent wall-clock bound. +pub const MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE: usize = 64; + /// Fail-closed failures while exchanging one bounded WebDriver BiDi `locateNodes` command. /// /// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded /// WebSocket error, raw response bytes must pass the core pre-parser admission contract, and the /// admitted document must correlate to the exact consumed command before result nodes are returned. -/// Protocol-shape, exhausted-deadline, and missing caller entropy refusals have no nested source -/// because none masks an underlying I/O or parser failure. +/// Protocol-shape, resource-budget, exhausted-deadline, and missing caller entropy refusals have no +/// nested source because none masks an underlying I/O or parser failure. #[derive(Debug)] pub enum WebDriverBiDiLocateNodesExchangeError { /// Bounded WebSocket frame write or read failed. @@ -31,6 +39,11 @@ pub enum WebDriverBiDiLocateNodesExchangeError { /// Original caller-supplied deadline budget for the complete exchange. exchange_timeout: Duration, }, + /// The peer exceeded the local resource budget for interleaved Ping/Pong frames. + ControlFrameLimitExceeded { + /// Maximum number of control frames admitted for one exchange. + maximum_control_frames: usize, + }, /// A server Ping required a fresh client masking key, but the caller supplied none. PongMaskingKeyUnavailable, /// The returned frame was neither an admissible control frame nor one complete text response. @@ -57,6 +70,12 @@ impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { formatter, "WebDriver BiDi locateNodes exchange exhausted its {exchange_timeout:?} end-to-end deadline before the next operation" ), + Self::ControlFrameLimitExceeded { + maximum_control_frames, + } => write!( + formatter, + "WebDriver BiDi locateNodes exchange exceeded the maximum {maximum_control_frames} interleaved control frames" + ), Self::PongMaskingKeyUnavailable => formatter.write_str( "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key", ), @@ -83,6 +102,7 @@ impl Error for WebDriverBiDiLocateNodesExchangeError { Self::ResponseDocument(error) => Some(error), Self::LocateNodesResponse(error) => Some(error), Self::ExchangeDeadlineExceeded { .. } + | Self::ControlFrameLimitExceeded { .. } | Self::PongMaskingKeyUnavailable | Self::UnexpectedResponseFrame { .. } => None, } @@ -127,8 +147,10 @@ impl WebDriverBiDiWebSocketEstablished { /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, /// and response read. Elapsed time is subtracted before every subsequent operation and the budget /// is never reset. The underlying frame boundary independently caps each frame at its existing - /// size ceiling, while the single exchange deadline bounds a peer that sends repeated valid - /// control frames. Any failure consumes this transport state and yields no reusable WebSocket + /// size ceiling. In addition, at most [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid + /// Ping/Pong frames are processed before the exchange fails closed, so RFC 6455 control-frame + /// interleaving cannot create an unbounded iteration budget even when the wall-clock deadline has + /// not yet expired. Any failure consumes this transport state and yields no reusable WebSocket /// stream, preventing a partially written/read protocol state from becoming later authority. /// /// The final complete text payload passes the existing bounded UTF-8/document admission, @@ -153,6 +175,7 @@ impl WebDriverBiDiWebSocketEstablished { command_masking_key, exchange_timeout, ))?; + let mut control_frame_count = 0_usize; loop { let remaining_timeout = @@ -161,8 +184,21 @@ impl WebDriverBiDiWebSocketEstablished { .read_frame(remaining_timeout) .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; established = next_established; + let opcode = frame.opcode(); + + if matches!(opcode, 0x9 | 0xa) { + if control_frame_count == MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE { + return Err( + WebDriverBiDiLocateNodesExchangeError::ControlFrameLimitExceeded { + maximum_control_frames: + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + }, + ); + } + control_frame_count += 1; + } - match frame.opcode() { + match opcode { 0x9 => { let masking_key = next_pong_masking_key(next_pong_key)?; let remaining_timeout = @@ -187,7 +223,7 @@ impl WebDriverBiDiWebSocketEstablished { return Err( WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { fin: frame.fin(), - opcode: frame.opcode(), + opcode, }, ); } @@ -207,7 +243,8 @@ mod tests { use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; use super::{ - WebDriverBiDiLocateNodesExchangeError, next_pong_masking_key, remaining_exchange_budget, + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, + next_pong_masking_key, remaining_exchange_budget, }; #[test] @@ -267,6 +304,16 @@ mod tests { assert!(deadline.source().is_none()); assert!(deadline.to_string().contains("end-to-end deadline")); + let control_limit = WebDriverBiDiLocateNodesExchangeError::ControlFrameLimitExceeded { + maximum_control_frames: MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + }; + assert!(control_limit.source().is_none()); + assert!( + control_limit + .to_string() + .contains("maximum 64 interleaved control frames") + ); + let missing_mask = WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable; assert!(missing_mask.source().is_none()); assert!( From e5ff620383db1476bb9759f9ee46517c778961e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:15:55 -0700 Subject: [PATCH 263/570] style(network): align control-frame bound formatting --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 5bb944080..00b69ba31 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -190,8 +190,7 @@ impl WebDriverBiDiWebSocketEstablished { if control_frame_count == MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE { return Err( WebDriverBiDiLocateNodesExchangeError::ControlFrameLimitExceeded { - maximum_control_frames: - MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + maximum_control_frames: MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, }, ); } From 504e921cbfad64f520bdd65ac8c71cc620715464 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:17:29 -0700 Subject: [PATCH 264/570] fix(network): export locateNodes control-frame budget --- crates/originweave-network/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index f84d440c3..76919b266 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -30,7 +30,9 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; -pub use webdriver_bidi_locate_nodes_exchange::WebDriverBiDiLocateNodesExchangeError; +pub use webdriver_bidi_locate_nodes_exchange::{ + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, +}; pub use webdriver_bidi_websocket_handshake::{ MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_FRAME_TIMEOUT, MAX_WEBSOCKET_OPENING_RESPONSE_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, From 80de9472b4b9ff320c2a9faf8fb515a718a65d96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:14:35 -0700 Subject: [PATCH 265/570] test(bap): require explicit reconciliation and dead-letter states --- .../originweave-bap/tests/task_lifecycle.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/originweave-bap/tests/task_lifecycle.rs b/crates/originweave-bap/tests/task_lifecycle.rs index 702be87b2..3776856cf 100644 --- a/crates/originweave-bap/tests/task_lifecycle.rs +++ b/crates/originweave-bap/tests/task_lifecycle.rs @@ -130,6 +130,55 @@ fn cancellation_and_expiry_cover_pre_dispatch_and_suspended_states() { } } +#[test] +fn reconciliation_requires_explicit_resolution_and_dead_letter_is_terminal() { + let mut task = running_task(); + let required = task + .apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation"); + assert_eq!(required.previous_state(), BapTaskState::Running); + assert_eq!( + required.current_state(), + BapTaskState::ReconciliationRequired + ); + assert!(!task.state().is_terminal()); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::ReconciliationRequired, + event: BapTaskEvent::Resume, + }) + ); + assert_eq!( + task.apply(BapTaskEvent::Succeed), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::ReconciliationRequired, + event: BapTaskEvent::Succeed, + }) + ); + assert_eq!(task.transition_sequence(), 3); + + task.apply(BapTaskEvent::ResolveReconciliation) + .expect("resolve reconciliation"); + assert_eq!(task.state(), BapTaskState::Running); + + task.apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation again"); + let dead_lettered = task + .apply(BapTaskEvent::DeadLetter) + .expect("dead-letter unresolved task"); + assert_eq!(dead_lettered.current_state(), BapTaskState::DeadLettered); + assert!(task.state().is_terminal()); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::TerminalState { + state: BapTaskState::DeadLettered, + }) + ); +} + fn running_task() -> BapTaskLifecycle { let mut task = BapTaskLifecycle::new(); task.apply(BapTaskEvent::Admit).expect("admit"); From dff029aa2788d72876788aa718dddaa577fa83a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:15:58 -0700 Subject: [PATCH 266/570] feat(bap): add fail-closed reconciliation lifecycle states --- crates/originweave-bap/src/lib.rs | 43 ++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index b2dbc6ba1..73cc8932e 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -23,6 +23,12 @@ pub enum BapTaskState { WaitingForExternalInput, /// Execution is suspended at a compatible recoverable checkpoint. Checkpointed, + /// Execution is suspended until an explicit reconciliation decision is recorded. + /// + /// The lifecycle state does not itself persist or authenticate reconciliation + /// evidence. A durable owner must preserve the complete evidence that caused + /// the task to enter this state before resolution is considered. + ReconciliationRequired, /// The declared post-condition completed successfully. Succeeded, /// The task reached a terminal execution failure. @@ -31,6 +37,11 @@ pub enum BapTaskState { Cancelled, /// The task exceeded its allowed lifetime and cannot resume. Expired, + /// The task was terminally removed from automatic execution after governed handling. + /// + /// Durable dead-letter evidence remains the responsibility of the persistence + /// boundary; this in-memory marker must not be treated as the evidence itself. + DeadLettered, } impl BapTaskState { @@ -39,7 +50,11 @@ impl BapTaskState { pub const fn is_terminal(self) -> bool { matches!( self, - Self::Succeeded | Self::Failed | Self::Cancelled | Self::Expired + Self::Succeeded + | Self::Failed + | Self::Cancelled + | Self::Expired + | Self::DeadLettered ) } } @@ -57,8 +72,14 @@ pub enum BapTaskEvent { WaitForExternalInput, /// Suspend a running task at a recoverable checkpoint. Checkpoint, - /// Resume a suspended task into governed execution. + /// Resume a normal suspended task into governed execution. Resume, + /// Suspend a running task because its external outcome requires reconciliation. + RequireReconciliation, + /// Explicitly resolve a reconciliation hold and return the task to governed execution. + ResolveReconciliation, + /// Terminally remove a running or reconciliation-held task from automatic execution. + DeadLetter, /// Record successful completion after the declared post-condition is verified. Succeed, /// Record terminal task failure. @@ -230,6 +251,9 @@ impl BapTaskLifecycle { /// /// Rejected events leave both state and sequence unchanged. Terminal states /// reject every later event before evaluating any normal transition rule. + /// Reconciliation cannot use the generic `Resume` event: it requires the + /// explicit `ResolveReconciliation` event so ambiguous external outcomes + /// cannot silently re-enter execution. pub fn apply( &mut self, event: BapTaskEvent, @@ -254,6 +278,17 @@ impl BapTaskLifecycle { | BapTaskState::Checkpointed, BapTaskEvent::Resume, ) => BapTaskState::Running, + (BapTaskState::Running, BapTaskEvent::RequireReconciliation) => { + BapTaskState::ReconciliationRequired + } + ( + BapTaskState::ReconciliationRequired, + BapTaskEvent::ResolveReconciliation, + ) => BapTaskState::Running, + ( + BapTaskState::Running | BapTaskState::ReconciliationRequired, + BapTaskEvent::DeadLetter, + ) => BapTaskState::DeadLettered, (BapTaskState::Running, BapTaskEvent::Succeed) => BapTaskState::Succeeded, (_, BapTaskEvent::Fail) => BapTaskState::Failed, (_, BapTaskEvent::Cancel) => BapTaskState::Cancelled, @@ -284,7 +319,8 @@ const fn reachable_snapshot(state: BapTaskState, transition_sequence: u64) -> bo BapTaskState::Running => transition_sequence >= 2 && transition_sequence.is_multiple_of(2), BapTaskState::WaitingForApproval | BapTaskState::WaitingForExternalInput - | BapTaskState::Checkpointed => { + | BapTaskState::Checkpointed + | BapTaskState::ReconciliationRequired => { transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) } BapTaskState::Succeeded => { @@ -293,5 +329,6 @@ const fn reachable_snapshot(state: BapTaskState, transition_sequence: u64) -> bo BapTaskState::Failed | BapTaskState::Cancelled | BapTaskState::Expired => { transition_sequence >= 1 } + BapTaskState::DeadLettered => transition_sequence >= 3, } } From 133825db8ed6ae7009f710347df9373039e61a3d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:17:01 -0700 Subject: [PATCH 267/570] test(bap): cover direct dead-letter admission bounds --- .../originweave-bap/tests/task_lifecycle.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/originweave-bap/tests/task_lifecycle.rs b/crates/originweave-bap/tests/task_lifecycle.rs index 3776856cf..a2c58822b 100644 --- a/crates/originweave-bap/tests/task_lifecycle.rs +++ b/crates/originweave-bap/tests/task_lifecycle.rs @@ -179,6 +179,29 @@ fn reconciliation_requires_explicit_resolution_and_dead_letter_is_terminal() { ); } +#[test] +fn running_task_may_dead_letter_but_pre_dispatch_task_may_not() { + let mut running = running_task(); + let transition = running + .apply(BapTaskEvent::DeadLetter) + .expect("dead-letter running task"); + assert_eq!(transition.previous_state(), BapTaskState::Running); + assert_eq!(transition.current_state(), BapTaskState::DeadLettered); + assert_eq!(transition.sequence(), 3); + assert!(running.state().is_terminal()); + + let mut created = BapTaskLifecycle::new(); + assert_eq!( + created.apply(BapTaskEvent::DeadLetter), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::Created, + event: BapTaskEvent::DeadLetter, + }) + ); + assert_eq!(created.state(), BapTaskState::Created); + assert_eq!(created.transition_sequence(), 0); +} + fn running_task() -> BapTaskLifecycle { let mut task = BapTaskLifecycle::new(); task.apply(BapTaskEvent::Admit).expect("admit"); From 416a5e5dd03bc7800b2c4dc68ec8eb79b3c4c33b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:17:23 -0700 Subject: [PATCH 268/570] test(bap): cover reconciliation and dead-letter recovery snapshots --- crates/originweave-bap/tests/task_lifecycle_recovery.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/originweave-bap/tests/task_lifecycle_recovery.rs b/crates/originweave-bap/tests/task_lifecycle_recovery.rs index 6b1240b90..67deae949 100644 --- a/crates/originweave-bap/tests/task_lifecycle_recovery.rs +++ b/crates/originweave-bap/tests/task_lifecycle_recovery.rs @@ -36,11 +36,14 @@ fn impossible_restored_snapshots_fail_closed() { (BapTaskState::WaitingForExternalInput, 4), (BapTaskState::Checkpointed, 2), (BapTaskState::Checkpointed, 4), + (BapTaskState::ReconciliationRequired, 2), + (BapTaskState::ReconciliationRequired, 4), (BapTaskState::Succeeded, 2), (BapTaskState::Succeeded, 4), (BapTaskState::Failed, 0), (BapTaskState::Cancelled, 0), (BapTaskState::Expired, 0), + (BapTaskState::DeadLettered, 2), ] { assert_eq!( BapTaskLifecycle::restore(state, sequence), @@ -63,10 +66,13 @@ fn valid_restored_snapshot_classes_remain_accepted() { (BapTaskState::WaitingForApproval, 3), (BapTaskState::WaitingForExternalInput, 5), (BapTaskState::Checkpointed, 7), + (BapTaskState::ReconciliationRequired, 3), (BapTaskState::Succeeded, 3), (BapTaskState::Failed, 1), (BapTaskState::Cancelled, 2), (BapTaskState::Expired, 4), + (BapTaskState::DeadLettered, 3), + (BapTaskState::DeadLettered, 4), ] { let task = BapTaskLifecycle::restore(state, sequence).expect("reachable snapshot"); assert_eq!(task.state(), state); From 7d3bb60bf3da76256055619d5f4575c67256a0f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:19:21 -0700 Subject: [PATCH 269/570] style(bap): apply canonical rustfmt to reconciliation states --- crates/originweave-bap/src/lib.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs index 73cc8932e..404a88c10 100644 --- a/crates/originweave-bap/src/lib.rs +++ b/crates/originweave-bap/src/lib.rs @@ -50,11 +50,7 @@ impl BapTaskState { pub const fn is_terminal(self) -> bool { matches!( self, - Self::Succeeded - | Self::Failed - | Self::Cancelled - | Self::Expired - | Self::DeadLettered + Self::Succeeded | Self::Failed | Self::Cancelled | Self::Expired | Self::DeadLettered ) } } @@ -281,10 +277,9 @@ impl BapTaskLifecycle { (BapTaskState::Running, BapTaskEvent::RequireReconciliation) => { BapTaskState::ReconciliationRequired } - ( - BapTaskState::ReconciliationRequired, - BapTaskEvent::ResolveReconciliation, - ) => BapTaskState::Running, + (BapTaskState::ReconciliationRequired, BapTaskEvent::ResolveReconciliation) => { + BapTaskState::Running + } ( BapTaskState::Running | BapTaskState::ReconciliationRequired, BapTaskEvent::DeadLetter, From a1535d843f25f3916cdb721336a9a2dfefdfc3a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:49:26 -0700 Subject: [PATCH 270/570] docs(evidence): bind ExtractionSchema doctoring authority --- docs/doctoring.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/doctoring.md b/docs/doctoring.md index f0133bb5d..d5e733c6c 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -82,6 +82,8 @@ RFC 9309 standardizes robots parsing, matching, error handling, and caching. It W3C PROV-O supplies interoperable Entity, Activity, Agent, derivation, attribution, and responsibility concepts. ISO 28500:2017, confirmed in 2023, defines WARC storage for protocol payloads, control information, metadata, transformations, duplicate detection, integrity, and segmentation. OriginWeave uses source hashes and locators in the safety kernel, then adds WARC and PROV adapters as separately testable modules. +The versioned `ExtractionSchema` is an admission and interpretation contract for typed extracted fields: each field is bounded, declares a value type, cardinality, normalization rule, and a canonical duplicate-free set of reviewed source-channel classes. That declaration does not create browser, network, model, secret, storage, retention, disclosure, or governance authority. PROV/WARC interoperability is therefore layered after the schema contract rather than inferred from it. + ### AI risk and prompt injection NIST AI 600-1 provides generative-AI lifecycle risk guidance. WASP demonstrates that web-navigation agents can follow low-effort indirect prompt injections. OriginWeave therefore separates trusted instructions, untrusted observations, and protected secrets at type and process boundaries rather than rely on prompting alone. From dd4935cfeee17dd0d4a73bc8e7e0150cc2648d65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 20:03:01 -0700 Subject: [PATCH 271/570] docs: record bounded locateNodes WebSocket exchange --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5d031b43..5a0167c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added - Bounded RFC 6455 frame transport on the established WebDriver BiDi stream: client text frames require a caller-supplied fresh mask key and are masked on the wire, server frames are required to be unmasked, reserved bits/opcodes and nonminimal lengths fail closed, and each frame is limited by payload and monotonic-I/O ceilings; this remains frame transport only and does not assemble BiDi messages or grant browser/Agent authority. +- Bounded WebDriver BiDi `browsingContext.locateNodes` exchange over the established peer-verified WebSocket stream: the exact consumed command is written as one masked text frame, one end-to-end deadline and a 64-frame Ping/Pong budget bound the exchange, each Pong requires a fresh caller-supplied masking key, and only one final bounded text response may pass raw-document admission, exact command correlation, and node-result admission; fragmentation, binary/continuation/close shapes, exhausted entropy, and over-budget control traffic fail closed without granting browser, origin, policy, typed-input, or Agent authority. - Bounded RFC 6455 WebDriver BiDi opening-response validation on the exact peer-verified stream: it admits only HTTP/1.1 `101`, case-insensitive `Upgrade`/`Connection` tokens, and the client-key-correlated `Sec-WebSocket-Accept` value within monotonic time and header-size ceilings; it restores blocking mode and still does not implement WebSocket frames or grant browser/Agent authority. - Bounded WebDriver BiDi loopback TCP transport that consumes one exact no-DNS connect target, retries only explicitly recoverable local transport failures within repository timeout and attempt ceilings, exposes the stream only after operating-system peer inspection and exact peer verification, supports a consuming handoff of the original stream with typed credential-free peer/session/TLS and bounded-attempt evidence, preserves typed causal errors, and performs no DNS, proxy/PAC, process authentication, TLS, WebSocket, BiDi message, browser-action, or Agent-authority step. - Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. From a1275db15225aeed061d7b841cbbb9605bc9d5c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:11:57 -0700 Subject: [PATCH 272/570] test(evidence): reject RFC 3986-invalid provenance path --- crates/originweave-evidence/tests/evidence.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/originweave-evidence/tests/evidence.rs b/crates/originweave-evidence/tests/evidence.rs index 2912180f1..11c3edd23 100644 --- a/crates/originweave-evidence/tests/evidence.rs +++ b/crates/originweave-evidence/tests/evidence.rs @@ -128,6 +128,7 @@ fn provenance_rejects_credential_bearing_or_ambiguous_source_urls() { "https://example.com/bad\\path", "https://example.com/\n", "https://example.com/a/%2f/b", + "https://example.com/[segment]", ] { assert_eq!( ProvenanceRecord::new( From ddcf15acf38060c6cb6e714a328ad9244041c59f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:20:01 -0700 Subject: [PATCH 273/570] fix(evidence): enforce RFC 3986 path characters --- crates/originweave-evidence/src/lib.rs | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index ad183e9eb..b38578044 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -224,6 +224,9 @@ fn validate_path(path: &str) -> Result<(), EvidenceError> { index += 3; continue; } + if !is_rfc3986_pchar(byte) { + return Err(EvidenceError::InvalidPath); + } segment.push(byte); index += 1; } @@ -233,6 +236,32 @@ fn validate_path(path: &str) -> Result<(), EvidenceError> { Ok(()) } +const fn is_rfc3986_pchar(byte: u8) -> bool { + matches!( + byte, + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + ) +} + const fn hexadecimal_value(byte: u8) -> Option { match byte { b'0'..=b'9' => Some(byte - b'0'), From 4c3f55b52d20d6963b3055bb8ed24c05fd692cf9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:23:51 -0700 Subject: [PATCH 274/570] docs: record RFC 3986 provenance path grammar --- docs/doctoring.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/doctoring.md b/docs/doctoring.md index f0133bb5d..6fb8a984c 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -82,6 +82,8 @@ RFC 9309 standardizes robots parsing, matching, error handling, and caching. It W3C PROV-O supplies interoperable Entity, Activity, Agent, derivation, attribution, and responsibility concepts. ISO 28500:2017, confirmed in 2023, defines WARC storage for protocol payloads, control information, metadata, transformations, duplicate detection, integrity, and segmentation. OriginWeave uses source hashes and locators in the safety kernel, then adds WARC and PROV adapters as separately testable modules. +RFC 3986 remains Internet Standard STD 66 for generic URI syntax and is updated by RFC 7320 and RFC 8820 without replacing its path grammar. Section 3.3 defines each path segment as `*pchar`, where literal path characters are unreserved characters, sub-delimiters, `:`, or `@`; `/` separates segments and other reserved characters such as `[` and `]` are not literal `pchar`. OriginWeave provenance URL admission therefore accepts only that literal ASCII `pchar` set plus validated percent-encoded octets and explicit slash separators. Existing stricter evidence-safety rules continue to reject encoded separators, dot-segment ambiguity, controls, whitespace, query strings, fragments, backslashes, and credential-bearing authority. This is URI-presentation validation only; it does not authorize the source origin, destination, network access, capture, disclosure, or retention. + ### AI risk and prompt injection NIST AI 600-1 provides generative-AI lifecycle risk guidance. WASP demonstrates that web-navigation agents can follow low-effort indirect prompt injections. OriginWeave therefore separates trusted instructions, untrusted observations, and protected secrets at type and process boundaries rather than rely on prompting alone. @@ -106,6 +108,8 @@ Autio, C., Schwartz, R., Dunietz, J., Jain, S., Stanley, M., Tabassi, E., Hall, Barth, A. (2011). *The web origin concept* (RFC 6454). Internet Engineering Task Force. https://doi.org/10.17487/RFC6454 +Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform resource identifier (URI): Generic syntax* (RFC 3986; STD 66). Internet Engineering Task Force. https://doi.org/10.17487/RFC3986 + Bonica, R., Cotton, M., Haberman, B., & Vegoda, L. (2017). *Updates to the special-purpose IP address registries* (RFC 8190). Internet Engineering Task Force. https://doi.org/10.17487/RFC8190 Chromium Authors. (n.d.). *Proxy support in Chrome* [Source documentation]. Chromium. https://chromium.googlesource.com/chromium/src/+/a3e71ebfa307d8760eb68b777e2998a869940092/net/docs/proxy.md @@ -170,4 +174,4 @@ World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04695 -Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 +Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 \ No newline at end of file From 3df03c0a5e6e98b916460cbc1caeeb1f709d1cd5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:24:19 -0700 Subject: [PATCH 275/570] docs: record provenance URI hardening --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d17419927..235692cf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Revocation is reported as not configured; the product makes no OCSP or CRL validation claim without supplied revocation evidence. - Every generic network header and query value is redacted before evidence leaves the trusted boundary, including conventionally benign field names containing attacker-controlled bytes. - Evidence capture enforces count and byte bounds and rejects credential-bearing source URLs, query strings, fragments, controls, whitespace, malformed percent escapes, encoded separators, dot segments, and backslash paths. +- Provenance source URL paths accept only RFC 3986 literal `pchar` syntax plus validated percent-encoded octets and slash separators, preventing raw general delimiters such as `[` and `]` or other invalid URI-presentation bytes from entering provenance identity. - Hard RAM and VRAM pressure pauses the active agent and rejects new admission; hard VRAM pressure also offloads a resident local model. - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. From ded60bc510f893e4ecaeb39046c73cc705d29551 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:50:06 -0700 Subject: [PATCH 276/570] test(network): reproduce long locateNodes exchange budget --- ..._bidi_locate_nodes_exchange_long_budget.rs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs new file mode 100644 index 000000000..5efe2a775 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs @@ -0,0 +1,111 @@ +use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; + +#[test] +fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("test server must accept"); + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .expect("test read timeout must configure"); + + let mut opening = Vec::new(); + let mut byte = [0_u8; 1]; + while !opening.ends_with(b"\r\n\r\n") { + stream + .read_exact(&mut byte) + .expect("opening request must arrive"); + opening.push(byte[0]); + } + stream + .write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + ) + .expect("opening response must be written"); + + let mut header = [0_u8; 2]; + stream + .read_exact(&mut header) + .expect("command frame header must arrive"); + assert_eq!(header[0], 0x81); + assert_ne!(header[1] & 0x80, 0); + let payload_length = usize::from(header[1] & 0x7f); + assert!(payload_length <= 125, "fixture command must use short frame encoding"); + let mut mask = [0_u8; 4]; + stream + .read_exact(&mut mask) + .expect("command mask must arrive"); + let mut payload = vec![0_u8; payload_length]; + stream + .read_exact(&mut payload) + .expect("command payload must arrive"); + + let response = RESPONSE_DOCUMENT.as_bytes(); + let response_length = u8::try_from(response.len()).expect("response must fit short frame"); + stream + .write_all(&[0x81, response_length]) + .expect("response header must be written"); + stream + .write_all(response) + .expect("response payload must be written"); + }); + + let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://{address}/session/{SESSION_ID}" + )) + .expect("test endpoint must be valid"); + let correlated = endpoint + .correlate_session_id(SESSION_ID) + .expect("test session must correlate"); + let target = correlated + .into_explicit_connect_target() + .expect("test target must be explicit"); + let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("test connection plan must be valid") + .connect() + .expect("test connection must succeed"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY) + .expect("test client key must be valid"); + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key) + .expect("test handshake plan must be valid") + .write_opening_request(Duration::from_millis(500)) + .expect("opening request must be written") + .read_opening_response(Duration::from_millis(500)) + .expect("opening response must be valid"); + + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2) + .expect("test query must be valid"); + let command = WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query) + .expect("test command must be valid"); + + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_secs(6), + ); + assert!(exchanged.is_ok(), "{exchanged:?}"); + assert!(server.join().is_ok()); +} From 2cb8e06875826a39246d528a03a5f1f8fa38b1a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:51:03 -0700 Subject: [PATCH 277/570] test(network): format long exchange budget regression --- ..._bidi_locate_nodes_exchange_long_budget.rs | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs index 5efe2a775..3b0c1f693 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs @@ -19,6 +19,37 @@ const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +fn read_client_text_frame(stream: &mut impl Read) { + let mut header = [0_u8; 2]; + stream + .read_exact(&mut header) + .expect("command frame header must arrive"); + assert_eq!(header[0], 0x81); + assert_ne!(header[1] & 0x80, 0); + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream + .read_exact(&mut extended) + .expect("16-bit command length must arrive"); + usize::from(u16::from_be_bytes(extended)) + } + 127 => panic!("test command must not require a 64-bit WebSocket length"), + _ => unreachable!("7-bit WebSocket payload marker"), + }; + + let mut mask = [0_u8; 4]; + stream + .read_exact(&mut mask) + .expect("command mask must arrive"); + let mut payload = vec![0_u8; payload_length]; + stream + .read_exact(&mut payload) + .expect("command payload must arrive"); +} + #[test] fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); @@ -45,22 +76,7 @@ fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { ) .expect("opening response must be written"); - let mut header = [0_u8; 2]; - stream - .read_exact(&mut header) - .expect("command frame header must arrive"); - assert_eq!(header[0], 0x81); - assert_ne!(header[1] & 0x80, 0); - let payload_length = usize::from(header[1] & 0x7f); - assert!(payload_length <= 125, "fixture command must use short frame encoding"); - let mut mask = [0_u8; 4]; - stream - .read_exact(&mut mask) - .expect("command mask must arrive"); - let mut payload = vec![0_u8; payload_length]; - stream - .read_exact(&mut payload) - .expect("command payload must arrive"); + read_client_text_frame(&mut stream); let response = RESPONSE_DOCUMENT.as_bytes(); let response_length = u8::try_from(response.len()).expect("response must fit short frame"); @@ -72,10 +88,9 @@ fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { .expect("response payload must be written"); }); - let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( - "ws://{address}/session/{SESSION_ID}" - )) - .expect("test endpoint must be valid"); + let endpoint = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}")) + .expect("test endpoint must be valid"); let correlated = endpoint .correlate_session_id(SESSION_ID) .expect("test session must correlate"); From 6cd64de5ea65e9d80ee258a364bcef913980bf10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:54:09 -0700 Subject: [PATCH 278/570] fix(network): cap locateNodes frame operations within exchange deadline --- .../webdriver_bidi_locate_nodes_exchange.rs | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 00b69ba31..c9dfbf77f 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -11,8 +11,8 @@ use originweave_core::{ }; use crate::webdriver_bidi_websocket_handshake::{ - WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, - WebDriverBiDiWebSocketMaskKey, + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, }; /// Maximum number of valid RFC 6455 Ping/Pong control frames one `locateNodes` exchange will process. @@ -121,6 +121,14 @@ fn remaining_exchange_budget( } } +fn remaining_frame_operation_budget( + exchange_timeout: Duration, + elapsed: Duration, +) -> Result { + remaining_exchange_budget(exchange_timeout, elapsed) + .map(|remaining| remaining.min(MAX_WEBSOCKET_FRAME_TIMEOUT)) +} + fn next_pong_masking_key( next_key: &mut dyn FnMut() -> Option, ) -> Result { @@ -145,13 +153,16 @@ impl WebDriverBiDiWebSocketEstablished { /// fragmented data, and reserved shapes are not reinterpreted as a BiDi response. /// /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, - /// and response read. Elapsed time is subtracted before every subsequent operation and the budget - /// is never reset. The underlying frame boundary independently caps each frame at its existing - /// size ceiling. In addition, at most [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid - /// Ping/Pong frames are processed before the exchange fails closed, so RFC 6455 control-frame - /// interleaving cannot create an unbounded iteration budget even when the wall-clock deadline has - /// not yet expired. Any failure consumes this transport state and yields no reusable WebSocket - /// stream, preventing a partially written/read protocol state from becoming later authority. + /// and response read. Elapsed time is subtracted before every operation and the budget is never + /// reset. Each individual frame operation is additionally capped at the established frame + /// timeout ceiling, so a longer end-to-end exchange budget remains valid without widening the + /// per-operation I/O bound. The underlying frame boundary independently caps each frame at its + /// existing size ceiling. In addition, at most + /// [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid Ping/Pong frames are processed before + /// the exchange fails closed, so RFC 6455 control-frame interleaving cannot create an unbounded + /// iteration budget even when the wall-clock deadline has not yet expired. Any failure consumes + /// this transport state and yields no reusable WebSocket stream, preventing a partially + /// written/read protocol state from becoming later authority. /// /// The final complete text payload passes the existing bounded UTF-8/document admission, /// complete WebDriver BiDi response parser, exact command-id correlation, and wire-derived node @@ -170,16 +181,18 @@ impl WebDriverBiDiWebSocketEstablished { WebDriverBiDiLocateNodesExchangeError, > { let started_at = Instant::now(); + let write_timeout = + remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; let mut established = map_established_frame_result(self.write_text_frame( command.as_json(), command_masking_key, - exchange_timeout, + write_timeout, ))?; let mut control_frame_count = 0_usize; loop { let remaining_timeout = - remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; + remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; let (next_established, frame) = established .read_frame(remaining_timeout) .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; @@ -201,7 +214,7 @@ impl WebDriverBiDiWebSocketEstablished { 0x9 => { let masking_key = next_pong_masking_key(next_pong_key)?; let remaining_timeout = - remaining_exchange_budget(exchange_timeout, started_at.elapsed())?; + remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; established = map_established_frame_result(established.write_pong_frame( frame.payload(), masking_key, @@ -243,7 +256,7 @@ mod tests { use super::{ MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, - next_pong_masking_key, remaining_exchange_budget, + next_pong_masking_key, remaining_exchange_budget, remaining_frame_operation_budget, }; #[test] @@ -269,6 +282,22 @@ mod tests { ); } + #[test] + fn exchange_budget_caps_each_frame_operation_without_resetting_total_time() { + assert_eq!( + remaining_frame_operation_budget(Duration::from_secs(6), Duration::ZERO).ok(), + Some(MAX_WEBSOCKET_FRAME_TIMEOUT) + ); + assert_eq!( + remaining_frame_operation_budget(Duration::from_secs(6), Duration::from_secs(2)).ok(), + Some(Duration::from_secs(4)) + ); + assert!( + remaining_frame_operation_budget(Duration::from_secs(6), Duration::from_secs(6)) + .is_err() + ); + } + #[test] fn pong_masking_key_source_fails_closed_when_entropy_is_unavailable() { let expected = crate::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); From 587a8389e763035bfd86f59efd6e3edbbfb95e40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:56:49 -0700 Subject: [PATCH 279/570] fix(network): preserve zero-budget frame refusal semantics --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index c9dfbf77f..7162f9161 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -153,11 +153,11 @@ impl WebDriverBiDiWebSocketEstablished { /// fragmented data, and reserved shapes are not reinterpreted as a BiDi response. /// /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, - /// and response read. Elapsed time is subtracted before every operation and the budget is never - /// reset. Each individual frame operation is additionally capped at the established frame - /// timeout ceiling, so a longer end-to-end exchange budget remains valid without widening the - /// per-operation I/O bound. The underlying frame boundary independently caps each frame at its - /// existing size ceiling. In addition, at most + /// and response read. Elapsed time is subtracted before every subsequent operation and the budget + /// is never reset. Each individual frame operation is additionally capped at the established + /// frame timeout ceiling, so a longer end-to-end exchange budget remains valid without widening + /// the per-operation I/O bound. The underlying frame boundary independently caps each frame at + /// its existing size ceiling. In addition, at most /// [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid Ping/Pong frames are processed before /// the exchange fails closed, so RFC 6455 control-frame interleaving cannot create an unbounded /// iteration budget even when the wall-clock deadline has not yet expired. Any failure consumes @@ -181,8 +181,7 @@ impl WebDriverBiDiWebSocketEstablished { WebDriverBiDiLocateNodesExchangeError, > { let started_at = Instant::now(); - let write_timeout = - remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; + let write_timeout = exchange_timeout.min(MAX_WEBSOCKET_FRAME_TIMEOUT); let mut established = map_established_frame_result(self.write_text_frame( command.as_json(), command_masking_key, From 63bd87a57e8bc0e431e8beac6e81452d5bad9095 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:59:31 -0700 Subject: [PATCH 280/570] test(network): satisfy strict lints for long exchange budget regression --- ..._bidi_locate_nodes_exchange_long_budget.rs | 179 +++++++++++------- 1 file changed, 107 insertions(+), 72 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs index 3b0c1f693..9bc5d190b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs @@ -1,5 +1,5 @@ use std::{ - io::{Read, Write}, + io::{self, Read, Write}, net::TcpListener, thread, time::Duration, @@ -19,101 +19,131 @@ const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; -fn read_client_text_frame(stream: &mut impl Read) { +fn read_client_text_frame(stream: &mut impl Read) -> io::Result<()> { let mut header = [0_u8; 2]; - stream - .read_exact(&mut header) - .expect("command frame header must arrive"); + stream.read_exact(&mut header)?; assert_eq!(header[0], 0x81); assert_ne!(header[1] & 0x80, 0); - let payload_length = match header[1] & 0x7f { - value @ 0..=125 => usize::from(value), - 126 => { - let mut extended = [0_u8; 2]; - stream - .read_exact(&mut extended) - .expect("16-bit command length must arrive"); - usize::from(u16::from_be_bytes(extended)) - } - 127 => panic!("test command must not require a 64-bit WebSocket length"), - _ => unreachable!("7-bit WebSocket payload marker"), + let payload_marker = header[1] & 0x7f; + let payload_length = if payload_marker <= 125 { + usize::from(payload_marker) + } else if payload_marker == 126 { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test command unexpectedly used a 64-bit WebSocket payload length", + )); }; let mut mask = [0_u8; 4]; - stream - .read_exact(&mut mask) - .expect("command mask must arrive"); + stream.read_exact(&mut mask)?; let mut payload = vec![0_u8; payload_length]; - stream - .read_exact(&mut payload) - .expect("command payload must arrive"); + stream.read_exact(&mut payload)?; + Ok(()) } #[test] fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); - let address = listener - .local_addr() - .expect("test listener address must be available"); - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("test server must accept"); - stream - .set_read_timeout(Some(Duration::from_secs(1))) - .expect("test read timeout must configure"); + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let address = listener.local_addr(); + assert!(address.is_ok(), "{address:?}"); + let Ok(address) = address else { + return; + }; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(Duration::from_secs(1)))?; let mut opening = Vec::new(); let mut byte = [0_u8; 1]; while !opening.ends_with(b"\r\n\r\n") { - stream - .read_exact(&mut byte) - .expect("opening request must arrive"); + stream.read_exact(&mut byte)?; opening.push(byte[0]); } - stream - .write_all( - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", - ) - .expect("opening response must be written"); + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; - read_client_text_frame(&mut stream); + read_client_text_frame(&mut stream)?; let response = RESPONSE_DOCUMENT.as_bytes(); - let response_length = u8::try_from(response.len()).expect("response must fit short frame"); - stream - .write_all(&[0x81, response_length]) - .expect("response header must be written"); - stream - .write_all(response) - .expect("response payload must be written"); + let response_length = u8::try_from(response.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test response unexpectedly exceeded short-frame length", + ) + })?; + stream.write_all(&[0x81, response_length])?; + stream.write_all(response)?; + Ok(()) }); let endpoint = - WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}")) - .expect("test endpoint must be valid"); - let correlated = endpoint - .correlate_session_id(SESSION_ID) - .expect("test session must correlate"); - let target = correlated - .into_explicit_connect_target() - .expect("test target must be explicit"); - let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) - .expect("test connection plan must be valid") - .connect() - .expect("test connection must succeed"); - let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY) - .expect("test client key must be valid"); - let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, key) - .expect("test handshake plan must be valid") - .write_opening_request(Duration::from_millis(500)) - .expect("opening request must be written") - .read_opening_response(Duration::from_millis(500)) - .expect("opening response must be valid"); + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}")); + assert!(endpoint.is_ok(), "{endpoint:?}"); + let Ok(endpoint) = endpoint else { + return; + }; + let correlated = endpoint.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + return; + }; + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + return; + }; + let connection_plan = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(connection_plan.is_ok(), "{connection_plan:?}"); + let Ok(connection_plan) = connection_plan else { + return; + }; + let connection = connection_plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + return; + }; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let handshake = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(handshake.is_ok(), "{handshake:?}"); + let Ok(handshake) = handshake else { + return; + }; + let opening = handshake.write_opening_request(Duration::from_millis(500)); + assert!(opening.is_ok(), "{opening:?}"); + let Ok(opening) = opening else { + return; + }; + let established = opening.read_opening_response(Duration::from_millis(500)); + assert!(established.is_ok(), "{established:?}"); + let Ok(established) = established else { + return; + }; - let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2) - .expect("test query must be valid"); - let command = WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query) - .expect("test command must be valid"); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2); + assert!(query.is_ok(), "{query:?}"); + let Ok(query) = query else { + return; + }; + let command = WebDriverBiDiLocateNodesCommand::new(7, "top-level-context", &query); + assert!(command.is_ok(), "{command:?}"); + let Ok(command) = command else { + return; + }; let exchanged = established.exchange_locate_nodes( command, @@ -122,5 +152,10 @@ fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { Duration::from_secs(6), ); assert!(exchanged.is_ok(), "{exchanged:?}"); - assert!(server.join().is_ok()); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(server_io) = server_result { + assert!(server_io.is_ok(), "{server_io:?}"); + } } From 17b8834b608cee6d5d5f680958470c5c84dcf9ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 22:00:40 -0700 Subject: [PATCH 281/570] style(network): apply canonical rustfmt to long-budget regression --- .../tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs index 9bc5d190b..84c09eed6 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_exchange_long_budget.rs @@ -102,8 +102,7 @@ fn exchange_budget_above_per_frame_ceiling_remains_a_valid_end_to_end_budget() { let Ok(target) = target else { return; }; - let connection_plan = - WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + let connection_plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); assert!(connection_plan.is_ok(), "{connection_plan:?}"); let Ok(connection_plan) = connection_plan else { return; From 4c95e213b67107da7fb92ab1cc71b99f7435bbd1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:05:40 -0700 Subject: [PATCH 282/570] test(network): reject malformed WebSocket Close payloads --- ...r_bidi_websocket_close_frame_validation.rs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs new file mode 100644 index 000000000..c34add5ce --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs @@ -0,0 +1,75 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn exchange_server_close_frame(frame: &[u8]) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let frame = frame.to_vec(); + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut opening = Vec::new(); + let mut byte = [0_u8; 1]; + while !opening.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte)?; + opening.push(byte[0]); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.write_all(&frame) + }); + + let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://{local_addr}/session/{SESSION_ID}" + ))?; + let correlated = endpoint.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)? + .connect()?; + let client_key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let handshake = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key)?; + let opening = handshake.write_opening_request(Duration::from_millis(500))?; + let established = opening.read_opening_response(Duration::from_millis(500))?; + let result = established.read_frame(Duration::from_millis(500)); + + let server_result = server + .join() + .map_err(|_| io::Error::other("close-frame validation test server panicked"))?; + server_result?; + + result + .err() + .ok_or_else(|| io::Error::other("invalid RFC 6455 Close frame was admitted").into()) +} + +#[test] +fn close_frame_rejects_one_byte_body_and_invalid_utf8_reason() -> Result<(), Box> { + let one_byte_body = exchange_server_close_frame(&[0x88, 0x01, 0x00])?; + assert!(matches!( + one_byte_body, + WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + )); + + let invalid_utf8_reason = + exchange_server_close_frame(&[0x88, 0x04, 0x03, 0xe8, 0xff, 0xff])?; + assert!(matches!( + invalid_utf8_reason, + WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + )); + Ok(()) +} From 8a1f7fd2cc98fbfcea7ce14fd81fca1e7ca98ca5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:07:08 -0700 Subject: [PATCH 283/570] test(network): format Close-frame regression --- ...iver_bidi_websocket_close_frame_validation.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs index c34add5ce..f80cdb8fc 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs @@ -15,7 +15,9 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; -fn exchange_server_close_frame(frame: &[u8]) -> Result> { +fn exchange_server_close_frame( + frame: &[u8], +) -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let frame = frame.to_vec(); @@ -34,13 +36,12 @@ fn exchange_server_close_frame(frame: &[u8]) -> Result Result<(), Box WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } )); - let invalid_utf8_reason = - exchange_server_close_frame(&[0x88, 0x04, 0x03, 0xe8, 0xff, 0xff])?; + let invalid_utf8_reason = exchange_server_close_frame(&[0x88, 0x04, 0x03, 0xe8, 0xff, 0xff])?; assert!(matches!( invalid_utf8_reason, WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } From b5f396f276987681f1604407e159066f9a33fab3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:11:56 -0700 Subject: [PATCH 284/570] test(network): cover valid and invalid Close payloads --- ...r_bidi_websocket_close_frame_validation.rs | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs index f80cdb8fc..863de2f88 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs @@ -15,9 +15,9 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; -fn exchange_server_close_frame( +fn exchange_server_frame( frame: &[u8], -) -> Result> { +) -> Result, Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let frame = frame.to_vec(); @@ -46,30 +46,30 @@ fn exchange_server_close_frame( let handshake = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key)?; let opening = handshake.write_opening_request(Duration::from_millis(500))?; let established = opening.read_opening_response(Duration::from_millis(500))?; - let result = established.read_frame(Duration::from_millis(500)); + let result = established + .read_frame(Duration::from_millis(500)) + .map(|_| ()); let server_result = server .join() .map_err(|_| io::Error::other("close-frame validation test server panicked"))?; server_result?; - result - .err() - .ok_or_else(|| io::Error::other("invalid RFC 6455 Close frame was admitted").into()) + Ok(result) } #[test] -fn close_frame_rejects_one_byte_body_and_invalid_utf8_reason() -> Result<(), Box> { - let one_byte_body = exchange_server_close_frame(&[0x88, 0x01, 0x00])?; +fn close_frame_enforces_payload_shape_and_utf8_reason() -> Result<(), Box> { assert!(matches!( - one_byte_body, - WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + exchange_server_frame(&[0x88, 0x01, 0x00])?, + Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { .. }) )); - - let invalid_utf8_reason = exchange_server_close_frame(&[0x88, 0x04, 0x03, 0xe8, 0xff, 0xff])?; assert!(matches!( - invalid_utf8_reason, - WebDriverBiDiWebSocketFrameError::MalformedFrame { .. } + exchange_server_frame(&[0x88, 0x04, 0x03, 0xe8, 0xff, 0xff])?, + Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { .. }) )); + + assert!(exchange_server_frame(&[0x88, 0x00])?.is_ok()); + assert!(exchange_server_frame(&[0x88, 0x04, 0x03, 0xe8, b'o', b'k'])?.is_ok()); Ok(()) } From 3da820f1f611e623db9aa27ca98fb4c1ed77780e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:17:39 -0700 Subject: [PATCH 285/570] fix(network): reject malformed WebSocket Close payloads --- .../src/webdriver_bidi_websocket_handshake.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 51e5e7331..4e42217f9 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -463,8 +463,9 @@ impl WebDriverBiDiWebSocketEstablished { /// Server-to-client frames must be unmasked. Data and continuation frames are returned one at /// a time so a later message layer can enforce fragmentation and JSON semantics; control frames /// are returned to that layer for protocol handling. Reserved bits/opcodes, oversized payloads, - /// noncanonical lengths, and incomplete reads fail closed. No frame grants browser/Agent - /// authority. + /// noncanonical lengths, and incomplete reads fail closed. Close frames additionally enforce the + /// RFC 6455 payload shape and UTF-8 reason contract before the frame is returned. No frame grants + /// browser/Agent authority. pub fn read_frame( self, frame_timeout: Duration, @@ -577,7 +578,7 @@ pub enum WebDriverBiDiWebSocketFrameError { /// Number of frame bytes consumed before EOF. bytes_read: usize, }, - /// The frame header violated RFC 6455 or the no-extension policy. + /// The frame header or RFC 6455 control-frame payload violated the protocol contract. MalformedFrame { /// Stable, non-secret reason for rejection. reason: &'static str, @@ -1153,6 +1154,18 @@ fn read_frame_with_clock( let payload_length = payload_length as usize; let mut payload = vec![0_u8; payload_length]; read_frame_bytes_with_clock(reader, &mut payload, &mut bytes_read, deadline, now)?; + if opcode == 0x8 { + if payload.len() == 1 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "Close frame payload must be empty or begin with a two-byte status code", + }); + } + if payload.len() > 1 && std::str::from_utf8(&payload[2..]).is_err() { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "Close frame reason is not valid UTF-8", + }); + } + } reader.set_nonblocking(false).map_err(|source| { WebDriverBiDiWebSocketFrameError::FrameReadFailed { bytes_read, source } })?; From 651d53e970f8e0588d8b36dd422a66c50af8f721 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:13:32 -0700 Subject: [PATCH 286/570] test(network): cover public WebSocket guard paths --- ...webdriver_bidi_websocket_coverage_tests.rs | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs new file mode 100644 index 000000000..4d7000d7e --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs @@ -0,0 +1,173 @@ +use std::{ + io::{self, Read, Write}, + net::TcpListener, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; + +use crate::{ + MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakeError, + WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketOpeningRequestSent, WebDriverBiDiWebSocketOpeningWriteError, +}; +use crate::webdriver_bidi_websocket_handshake::WebDriverBiDiWebSocketHandshakePlan; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn client_key() -> WebDriverBiDiWebSocketClientKey { + WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ==") + .expect("test client key must be valid") +} + +fn loopback_plan(scheme: &str) -> (WebDriverBiDiTcpConnectionPlan, TcpListener) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( + "{scheme}://{address}/session/{SESSION_ID}" + )) + .expect("test endpoint must be valid"); + let correlated = endpoint + .correlate_session_id(SESSION_ID) + .expect("test session must correlate"); + let target = correlated + .into_explicit_connect_target() + .expect("test target must be explicit"); + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("test connection plan must be valid"); + (plan, listener) +} + +fn join_server(server: thread::JoinHandle>) { + server + .join() + .expect("test loopback server must not panic") + .expect("test loopback server must complete"); +} + +fn opening_sent() -> ( + WebDriverBiDiWebSocketOpeningRequestSent, + thread::JoinHandle>, +) { + let (plan, listener) = loopback_plan("ws"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept()?; + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request)?; + Ok(()) + }); + let connection = plan.connect().expect("test connection must succeed"); + let sent = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) + .expect("test handshake plan must be valid") + .write_opening_request(Duration::from_secs(1)) + .expect("test opening request must be written"); + (sent, server) +} + +fn established() -> ( + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +) { + let (plan, listener) = loopback_plan("ws"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept()?; + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + Ok(()) + }); + let connection = plan.connect().expect("test connection must succeed"); + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) + .expect("test handshake plan must be valid") + .write_opening_request(Duration::from_secs(1)) + .expect("test opening request must be written") + .read_opening_response(Duration::from_secs(1)) + .expect("test opening response must be valid"); + (established, server) +} + +#[test] +fn public_client_key_guard_rejects_noncanonical_length() { + assert!(matches!( + WebDriverBiDiWebSocketClientKey::new("AAAAAAAAAAAAAAAAAAAA=="), + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); +} + +#[test] +fn opening_plan_rejects_plain_transport_for_tls_required_target() { + let (plan, listener) = loopback_plan("wss"); + let server = thread::spawn(move || listener.accept().map(|_| ())); + let connection = plan.connect().expect("test connection must succeed"); + + assert!(matches!( + WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()), + Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired) + )); + join_server(server); +} + +#[test] +fn public_opening_write_guard_rejects_zero_and_over_ceiling_timeouts() { + for timeout in [ + Duration::ZERO, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT + Duration::from_nanos(1), + ] { + let (plan, listener) = loopback_plan("ws"); + let server = thread::spawn(move || listener.accept().map(|_| ())); + let connection = plan.connect().expect("test connection must succeed"); + let handshake = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) + .expect("test handshake plan must be valid"); + + assert!(matches!( + handshake.write_opening_request(timeout), + Err(WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout, + maximum_timeout, + }) if write_timeout == timeout && maximum_timeout == MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT + )); + join_server(server); + } +} + +#[test] +fn public_opening_response_guard_rejects_zero_and_over_ceiling_timeouts() { + for timeout in [ + Duration::ZERO, + MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT + Duration::from_nanos(1), + ] { + let (sent, server) = opening_sent(); + assert!(matches!( + sent.read_opening_response(timeout), + Err(WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { + response_timeout, + maximum_timeout, + }) if response_timeout == timeout && maximum_timeout == MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT + )); + join_server(server); + } +} + +#[test] +fn public_text_frame_guard_rejects_payload_above_reviewed_ceiling() { + let (established, server) = established(); + let oversized = "x".repeat(MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE + 1); + let masking_key = WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]); + + assert!(matches!( + established.write_text_frame(&oversized, masking_key, Duration::from_secs(1)), + Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes, + maximum_bytes, + }) if payload_bytes == oversized.len() && maximum_bytes == MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE + )); + join_server(server); +} From e8d8aa871dfc30485c0c90a06ea97bec9b53fe08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:13:56 -0700 Subject: [PATCH 287/570] test(network): exercise exact public guard coverage --- crates/originweave-network/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index ddca0ec0a..382166340 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -17,6 +17,9 @@ mod connection; mod webdriver_bidi_connection; mod webdriver_bidi_websocket_control; mod webdriver_bidi_websocket_handshake; +#[cfg(test)] +#[allow(clippy::expect_used)] +mod webdriver_bidi_websocket_coverage_tests; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, From 7964efe501c5920420b5ba372b798974283db879 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:15:45 -0700 Subject: [PATCH 288/570] style(network): apply canonical rustfmt to guard coverage --- .../src/webdriver_bidi_websocket_coverage_tests.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs index 4d7000d7e..b48aab9d0 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs @@ -7,6 +7,7 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; +use crate::webdriver_bidi_websocket_handshake::WebDriverBiDiWebSocketHandshakePlan; use crate::{ MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiTcpConnectionPlan, @@ -15,7 +16,6 @@ use crate::{ WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningRequestSent, WebDriverBiDiWebSocketOpeningWriteError, }; -use crate::webdriver_bidi_websocket_handshake::WebDriverBiDiWebSocketHandshakePlan; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -29,10 +29,9 @@ fn loopback_plan(scheme: &str) -> (WebDriverBiDiTcpConnectionPlan, TcpListener) let address = listener .local_addr() .expect("test listener address must be available"); - let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( - "{scheme}://{address}/session/{SESSION_ID}" - )) - .expect("test endpoint must be valid"); + let endpoint = + WebDriverBiDiWebSocketEndpoint::new(&format!("{scheme}://{address}/session/{SESSION_ID}")) + .expect("test endpoint must be valid"); let correlated = endpoint .correlate_session_id(SESSION_ID) .expect("test session must correlate"); From 3c02b8e9e228bd80e1718dcc6533d485edf2ce8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:16:02 -0700 Subject: [PATCH 289/570] style(network): format coverage module declaration --- crates/originweave-network/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 382166340..cfcecc864 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -16,10 +16,10 @@ mod connection; mod webdriver_bidi_connection; mod webdriver_bidi_websocket_control; -mod webdriver_bidi_websocket_handshake; #[cfg(test)] #[allow(clippy::expect_used)] mod webdriver_bidi_websocket_coverage_tests; +mod webdriver_bidi_websocket_handshake; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, From ed6aa42894607db66ec73835a36f4e68bca71f4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:21:03 -0700 Subject: [PATCH 290/570] test(network): close duplicate coverage branch gaps --- ...webdriver_bidi_websocket_coverage_tests.rs | 79 +++++++++++++++++-- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs index b48aab9d0..b7508f0c6 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_coverage_tests.rs @@ -12,9 +12,10 @@ use crate::{ MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, - WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakeError, - WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketMaskKey, - WebDriverBiDiWebSocketOpeningRequestSent, WebDriverBiDiWebSocketOpeningWriteError, + WebDriverBiDiWebSocketFrame, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningRequestSent, + WebDriverBiDiWebSocketOpeningWriteError, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -93,12 +94,53 @@ fn established() -> ( (established, server) } +fn read_server_frame( + frame: &[u8], +) -> Result { + let (plan, listener) = loopback_plan("ws"); + let frame = frame.to_vec(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut opening = Vec::new(); + let mut byte = [0_u8; 1]; + while !opening.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte)?; + opening.push(byte[0]); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.write_all(&frame) + }); + let connection = plan.connect().expect("test connection must succeed"); + let established = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) + .expect("test handshake plan must be valid") + .write_opening_request(Duration::from_millis(500)) + .expect("test opening request must be written") + .read_opening_response(Duration::from_millis(500)) + .expect("test opening response must be valid"); + let result = established + .read_frame(Duration::from_millis(500)) + .map(|(_, frame)| frame); + join_server(server); + result +} + #[test] -fn public_client_key_guard_rejects_noncanonical_length() { - assert!(matches!( - WebDriverBiDiWebSocketClientKey::new("AAAAAAAAAAAAAAAAAAAA=="), - Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) - )); +fn public_client_key_guard_rejects_each_noncanonical_shape() { + for invalid_key in [ + "AAAAAAAAAAAAAAAAAAAA==", + "dGhlIHNhbXBsZSBub25jZ!==", + "dGhlIHNhbXBsZSBub25jZR==", + "dGhlIHNhbXBsZSBub25jZQA=", + "dGhlIHNhbXBsZSBub25jZQ=A", + ] { + assert!(matches!( + WebDriverBiDiWebSocketClientKey::new(invalid_key), + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); + } } #[test] @@ -170,3 +212,24 @@ fn public_text_frame_guard_rejects_payload_above_reviewed_ceiling() { )); join_server(server); } + +#[test] +fn close_frame_validation_covers_each_payload_shape_in_unit_build() { + let empty = read_server_frame(&[0x88, 0x00]).expect("empty Close frame must be valid"); + assert_eq!(empty.opcode(), 0x8); + assert!(empty.payload().is_empty()); + + assert!(matches!( + read_server_frame(&[0x88, 0x01, 0x00]), + Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { .. }) + )); + + let valid_reason = read_server_frame(&[0x88, 0x04, 0x03, 0xe8, b'o', b'k']) + .expect("valid Close reason must be accepted"); + assert_eq!(valid_reason.payload(), &[0x03, 0xe8, b'o', b'k']); + + assert!(matches!( + read_server_frame(&[0x88, 0x03, 0x03, 0xe8, 0xff]), + Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { .. }) + )); +} From 1e900ae028c395afac0b21aa3845c0ed3de50096 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:47:18 -0700 Subject: [PATCH 291/570] test(bap): require lifecycle architecture decision --- tests/test_repository_contract.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 78c636b60..057a0011b 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -60,6 +60,7 @@ def test_required_architecture_and_governance_documents_exist(self) -> None: "docs/adr/0005-direct-socket-binding.md", "docs/adr/0006-tls-server-identity.md", "docs/adr/0009-hourly-agent-credential-boundary.md", + "docs/adr/0016-bap-task-lifecycle-authority.md", "docs/superpowers/specs/2026-08-06-resolved-destination-policy-design.md", "docs/superpowers/specs/2026-08-06-direct-socket-binding-design.md", "docs/superpowers/specs/2026-08-06-tls-server-identity-design.md", From 852b362567863b7854f3f64226f08082ed72d70e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:49:27 -0700 Subject: [PATCH 292/570] docs(bap): record task lifecycle state authority --- docs/adr/0016-bap-task-lifecycle-authority.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/adr/0016-bap-task-lifecycle-authority.md diff --git a/docs/adr/0016-bap-task-lifecycle-authority.md b/docs/adr/0016-bap-task-lifecycle-authority.md new file mode 100644 index 000000000..54fae8607 --- /dev/null +++ b/docs/adr/0016-bap-task-lifecycle-authority.md @@ -0,0 +1,123 @@ +# ADR 0016: BAP task lifecycle and state authority + +- **Status:** Proposed +- **Date:** 2026-08-22 +- **Supersedes:** None +- **Superseded by:** None + +## Context + +OriginWeave needs a deterministic lifecycle primitive for governed browser-agent work before durable BAP transport, persistence, idempotency, or crash recovery can be added safely. A task state is security-relevant because downstream components may use it to decide whether work may start, resume, complete, reconcile, or terminate. If adapters, persistence layers, browser drivers, or recovery code can mint state independently, OriginWeave would inherit ambient execution authority from whichever boundary supplied the most convenient state value. + +The `originweave-bap` crate therefore introduces a typed in-memory state machine with monotonic transition receipts and fail-closed recovery validation. The crate deliberately owns no browser, network, model, secret, approval, persistence, tenant-authentication, or protocol authority. External protocols may project lifecycle intent into this kernel, but protocol metadata cannot bypass its transition rules or upgrade a task's authority. + +## Decision drivers + +- Keep task-state authority explicit and deterministic rather than distributed across protocol adapters. +- Prevent stale, unreachable, or terminal lifecycle snapshots from reopening governed work. +- Preserve a monotonic transition sequence suitable for later durable replay evidence without claiming persistence today. +- Separate lifecycle state from browser, network, secret, model, approval, and tenant authority. +- Make waiting, checkpoint, reconciliation, completion, cancellation, expiry, and dead-letter behavior typed and testable. +- Keep recovery validation fail closed when a supplied state/sequence pair cannot arise from the reviewed state machine. + +## Assumptions and authority boundaries + +- The lifecycle is an in-memory logical primitive; it is not a durable task repository. +- Creating or restoring a lifecycle does not authenticate a caller, tenant, browser session, document, origin, destination, secret, model, approval, or external side effect. +- A transition receipt proves only what this in-memory lifecycle instance accepted. It is not durable audit evidence until a separate authenticated persistence boundary stores it. +- Waiting for approval is a lifecycle condition, not proof that approval exists. A later approval authority must independently authenticate and authorize any decision before resumption. +- `Succeeded` is entered only after a caller asserts that its separately governed post-condition has been verified; the lifecycle does not itself verify that post-condition. +- Reconciliation and dead-letter states preserve control-flow intent only. Durable reconciliation evidence remains the responsibility of a later persistence/recovery boundary. + +## Options considered + +### Let each BAP or MCP adapter own its own state machine + +Rejected. Adapter-local state machines would duplicate policy, make recovery semantics drift by protocol, and allow external protocol metadata to become implicit OriginWeave execution authority. + +### Store task state as an unrestricted string or integer + +Rejected. Untyped state admits unknown values, weakens exhaustive transition review, and makes invalid or stale recovery snapshots difficult to reject deterministically. + +### Allow restored state to resume whenever the state name looks resumable + +Rejected. State-only recovery loses monotonic history. A state/sequence pair that cannot be reached through the reviewed transitions must fail closed rather than becoming execution authority. + +### Centralize logical lifecycle transitions in a typed Rust kernel + +Selected. + +## Decision + +If Accepted, OriginWeave applies these lifecycle rules: + +1. **One typed kernel owns logical BAP task state.** `originweave-bap` is the canonical state-transition authority for the task lifecycle represented by this contract. Protocol adapters may request transitions but do not mint lifecycle state directly. +2. **Transitions are explicit and fail closed.** The kernel accepts only reviewed event/state combinations. Invalid events preserve the existing state and sequence and return a typed error. +3. **Terminal states never reopen.** `Succeeded`, `Failed`, `Cancelled`, `Expired`, and `DeadLettered` reject later lifecycle events. +4. **Waiting and checkpoint states require explicit resumption.** Approval wait, external-input wait, and checkpoint states do not silently become running work. +5. **Reconciliation is distinct from normal suspension.** A task in `ReconciliationRequired` cannot use the ordinary resume path; it requires explicit reconciliation resolution or governed dead-letter handling. +6. **Transition sequence is monotonic and bounded.** Every accepted transition advances the sequence exactly once. Sequence exhaustion fails closed instead of wrapping. +7. **Recovery validates reachability.** A supplied state/sequence snapshot must be reachable under the same reviewed state machine. Unreachable snapshots are rejected with a typed restore error. +8. **Lifecycle state grants no ambient authority.** A `Running`, resumable, or otherwise valid lifecycle state does not authorize browser I/O, network destinations, secret resolution, model access, approvals, external protocol operations, or tenant access. Those authorities must be revalidated by their owning boundaries. +9. **Durability is a separate owner.** This contract does not claim atomic persistence, idempotency, locking, authenticated replay evidence, side-effect reconciliation, or crash-safe recovery. Later durable components must bind those concerns to lifecycle receipts without weakening this state authority. +10. **External protocol state is projected, not inherited.** BAP, MCP, WebDriver BiDi, CDP, or other adapters may translate reviewed external events into typed lifecycle requests only after their own authentication and policy checks. External state labels cannot overwrite the kernel directly. + +## Consequences + +OriginWeave gains one reviewable state authority that later transport, idempotency, persistence, and recovery slices can compose without duplicating transition semantics. Invalid transitions and unreachable recovery snapshots have deterministic typed failures, while terminal and reconciliation states have explicit closure behavior. + +The trade-off is that adapters and durable stores must perform explicit mapping and validation instead of assigning state directly. The current slice also cannot claim commercial crash recovery until durable authenticated evidence and side-effect reconciliation are implemented separately. + +## Failure and degraded behavior + +- An invalid event returns a typed transition error and leaves state/history unchanged. +- A terminal lifecycle rejects all later events rather than reopening work. +- Sequence exhaustion returns a typed failure rather than wrapping or silently reusing an identifier. +- An unreachable restored state/sequence pair is rejected rather than normalized into a nearby valid state. +- Missing browser, tenant, policy, destination, secret, approval, persistence, or recovery authority is not converted into lifecycle success. +- If a future adapter cannot map external protocol state without ambiguity, it must fail closed or require reconciliation rather than inventing a lifecycle transition. + +## Security / privacy / governance impact + +This decision narrows authority. It prevents external protocol metadata, stale snapshots, or arbitrary state assignment from becoming execution authority and keeps lifecycle state separate from sensitive-data, secret, browser, network, model, approval, and tenant boundaries. The lifecycle stores no secret values or personal-data payloads by itself. Any future persistent representation must independently satisfy OriginWeave data-governance, retention, tenant-isolation, integrity, and evidence requirements. + +## Tests and acceptance evidence + +The owning branch must keep executable evidence for: + +- the reviewed created/admitted/running/waiting/checkpointed/reconciliation/terminal transition paths; +- fail-closed invalid transitions with no sequence advancement; +- terminal irreversibility; +- cancellation and expiry across allowed pre-dispatch and suspended states; +- explicit reconciliation resolution and governed dead-letter behavior; +- monotonic transition receipts and sequence-exhaustion failure; +- recovery acceptance for reachable snapshots and rejection for unreachable snapshots; and +- deterministic public Rust error contracts. + +Repository contracts must also require this ADR so the `originweave-bap` control-plane boundary cannot remain undocumented while the crate is present. Exact protected-main acceptance still depends on current-head CI, exact owned-production coverage, rustdoc, security evidence, review, live governance, and integration state; ADR presence does not substitute for those gates. + +## Migration and rollback + +No database migration is introduced. Existing callers on this branch construct the typed lifecycle directly. A future durable task repository should persist state and transition evidence in an authenticated form that can be validated by this kernel rather than introducing a second transition authority. + +Rollback before acceptance is removal of the active BAP lifecycle branch and its Proposed ADR. After acceptance, rollback or replacement must preserve fail-closed terminal/recovery semantics or explicitly supersede this ADR with a reviewed migration for any persisted lifecycle representation. + +## Open follow-ups + +- Bind durable idempotency receipts to exact accepted transitions without making retry metadata task authority. +- Define authenticated persistence, atomicity, and concurrency semantics for lifecycle plus command evidence. +- Define crash-recovery classification and reconciliation for ambiguous external side effects. +- Map authenticated BAP/MCP transport messages into typed lifecycle requests without ambient protocol authority. +- Propagate cancellation and expiry into real browser/process supervision only after the corresponding runtime authority exists. + +## Supersession / reversal conditions + +Supersede this ADR if OriginWeave replaces the BAP lifecycle model, introduces a materially different durable event-sourced task authority, or moves canonical task-state ownership to another reviewed component. A successor must preserve explicit state authority, terminal fail-closure, monotonic recovery evidence, and the rule that lifecycle state cannot mint unrelated browser/network/secret/model/approval/tenant authority. + +## References + +ContextualWisdomLab. (2026). *OriginWeave architecture* [Repository specification]. *OriginWeave*. [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) + +ContextualWisdomLab. (2026). *OriginWeave architecture decision records* [Repository specification]. *OriginWeave*. [`README.md`](README.md) + +ContextualWisdomLab. (2026). *Agent development contract* [Repository specification]. *OriginWeave*. [`../../AGENTS.md`](../../AGENTS.md) From fde30a570b27ff625c64902cb9c9832ba6758340 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:56:28 -0700 Subject: [PATCH 293/570] test(network): reject forbidden WebSocket close codes --- ...ver_bidi_websocket_close_frame_validation.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs index 863de2f88..c296977cb 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs @@ -73,3 +73,20 @@ fn close_frame_enforces_payload_shape_and_utf8_reason() -> Result<(), Box Result<(), Box> { + for status_code in [999_u16, 1005, 1006, 1015, 5000] { + let [high, low] = status_code.to_be_bytes(); + assert!(matches!( + exchange_server_frame(&[0x88, 0x02, high, low])?, + Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { .. }) + )); + } + + for status_code in [1000_u16, 3000, 4000] { + let [high, low] = status_code.to_be_bytes(); + assert!(exchange_server_frame(&[0x88, 0x02, high, low])?.is_ok()); + } + Ok(()) +} From f9f31ccc1a3d1102be1dd90d72104ab0ec994f27 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:02:53 -0700 Subject: [PATCH 294/570] fix(network): validate WebSocket close status codes --- .../src/webdriver_bidi_websocket_handshake.rs | 1963 ++--------------- 1 file changed, 128 insertions(+), 1835 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 4e42217f9..d17de8d5f 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -190,16 +190,6 @@ impl WebDriverBiDiWebSocketHandshakePlan { } /// Write the complete bounded opening request on the exact verified stream within one deadline. - /// - /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an - /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the - /// destination, or retries after any other I/O failure. A partial write that cannot finish before - /// the same monotonic deadline is an error and yields no successful handoff. Before success, the - /// operation-local socket write timeout is cleared so the next separately reviewed protocol stage - /// cannot inherit stale timeout authority. Success preserves the live stream, exact transport - /// evidence, and client key for a separately reviewed server handshake validator. It does not - /// read or validate the server response and therefore does not establish WebSocket protocol state - /// or browser/Agent authority. pub fn write_opening_request( self, write_timeout: Duration, @@ -234,14 +224,6 @@ impl WebDriverBiDiWebSocketHandshakePlan { } } -/// A live verified stream after the complete client opening request has been written. -/// -/// This state proves only that the exact bounded RFC 6455 client request reached the operating -/// system's verified TCP stream before the configured deadline and that this operation's socket write -/// timeout was cleared before handoff. It deliberately does not claim that the peer returned `101 -/// Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket is established, or -/// that the peer is the expected Chromium/ChromeDriver process. Those remain separate fail-closed -/// boundaries. pub struct WebDriverBiDiWebSocketOpeningRequestSent { pub(crate) stream: TcpStream, transport_evidence: WebDriverBiDiTcpConnectionEvidence, @@ -256,10 +238,7 @@ impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") .field("stream_local_addr", &self.stream.local_addr().ok()) .field("transport_evidence", &self.transport_evidence) - .field( - "client_key", - &"", - ) + .field("client_key", &"") .field("request_byte_count", &self.request_byte_count) .field("write_timeout", &self.write_timeout) .finish() @@ -267,36 +246,26 @@ impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { } impl WebDriverBiDiWebSocketOpeningRequestSent { - /// Borrow the exact verified transport evidence retained with this live stream. #[must_use] pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { &self.transport_evidence } - /// Borrow the exact client key required to validate the later server accept value. #[must_use] pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { &self.client_key } - /// Return the exact number of opening-request bytes written before success was emitted. #[must_use] pub const fn request_byte_count(&self) -> usize { self.request_byte_count } - /// Return the total write deadline configured for this opening request. #[must_use] pub const fn write_timeout(&self) -> Duration { self.write_timeout } - /// Read and validate the bounded RFC 6455 server opening response on this exact stream. - /// - /// Success proves only an HTTP/1.1 `101 Switching Protocols` response with the required - /// `Upgrade`, `Connection`, and client-key-correlated `Sec-WebSocket-Accept` headers. The - /// response body, WebSocket frames, browser process identity, TLS, and browser/Agent authority - /// remain separate boundaries. pub fn read_opening_response( self, response_timeout: Duration, @@ -335,11 +304,6 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { } } -/// A live verified stream after both RFC 6455 opening messages were validated. -/// -/// This state does not implement WebSocket framing or grant browser, page, policy, or Agent -/// authority. It retains the exact transport evidence and client key so later protocol stages can -/// remain correlated with the verified peer and opening handshake. pub struct WebDriverBiDiWebSocketEstablished { pub(crate) stream: TcpStream, transport_evidence: WebDriverBiDiTcpConnectionEvidence, @@ -357,10 +321,7 @@ impl fmt::Debug for WebDriverBiDiWebSocketEstablished { .debug_struct("WebDriverBiDiWebSocketEstablished") .field("stream_local_addr", &self.stream.local_addr().ok()) .field("transport_evidence", &self.transport_evidence) - .field( - "client_key", - &"", - ) + .field("client_key", &"") .field("response_status", &self.response_status) .field("response_byte_count", &self.response_byte_count) .field("response_timeout", &self.response_timeout) @@ -371,54 +332,41 @@ impl fmt::Debug for WebDriverBiDiWebSocketEstablished { } impl WebDriverBiDiWebSocketEstablished { - /// Borrow the exact verified transport evidence retained with this live stream. #[must_use] pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { &self.transport_evidence } - /// Borrow the exact client key correlated with the validated server accept value. #[must_use] pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { &self.client_key } - /// Return the validated HTTP status code, currently always `101` on success. #[must_use] pub const fn response_status(&self) -> u16 { self.response_status } - /// Return the number of HTTP opening-response bytes consumed through its header terminator. #[must_use] pub const fn response_byte_count(&self) -> usize { self.response_byte_count } - /// Return the total response deadline configured for this opening response. #[must_use] pub const fn response_timeout(&self) -> Duration { self.response_timeout } - /// Return the number of request bytes written before the response was read. #[must_use] pub const fn request_byte_count(&self) -> usize { self.request_byte_count } - /// Return the total write deadline configured for the preceding opening request. #[must_use] pub const fn write_timeout(&self) -> Duration { self.write_timeout } - /// Write one unfragmented, masked UTF-8 text frame on this verified stream. - /// - /// The operation consumes the established state and returns it only after the complete frame - /// is written and the temporary socket timeout is cleared. The caller must provide a fresh, - /// unpredictable masking key for this frame; it is never exposed in evidence or debug output. - /// This method does not translate JSON, create a BiDi session, or grant browser/Agent authority. pub fn write_text_frame( self, text: &str, @@ -458,14 +406,6 @@ impl WebDriverBiDiWebSocketEstablished { }) } - /// Read one bounded RFC 6455 frame from this verified stream. - /// - /// Server-to-client frames must be unmasked. Data and continuation frames are returned one at - /// a time so a later message layer can enforce fragmentation and JSON semantics; control frames - /// are returned to that layer for protocol handling. Reserved bits/opcodes, oversized payloads, - /// noncanonical lengths, and incomplete reads fail closed. Close frames additionally enforce the - /// RFC 6455 payload shape and UTF-8 reason contract before the frame is returned. No frame grants - /// browser/Agent authority. pub fn read_frame( self, frame_timeout: Duration, @@ -499,7 +439,6 @@ impl WebDriverBiDiWebSocketEstablished { } } -/// One validated WebSocket frame received from the established peer. #[derive(Debug, Eq, PartialEq)] pub struct WebDriverBiDiWebSocketFrame { fin: bool, @@ -508,19 +447,16 @@ pub struct WebDriverBiDiWebSocketFrame { } impl WebDriverBiDiWebSocketFrame { - /// Return whether this is the final frame in its message. #[must_use] pub const fn fin(&self) -> bool { self.fin } - /// Return the RFC 6455 opcode without interpreting application semantics. #[must_use] pub const fn opcode(&self) -> u8 { self.opcode } - /// Borrow the bounded, unmasked application payload. #[must_use] pub fn payload(&self) -> &[u8] { &self.payload @@ -537,81 +473,53 @@ fn validate_frame_timeout(frame_timeout: Duration) -> Result<(), WebDriverBiDiWe Ok(()) } -/// Fail-closed errors while reading or writing one bounded WebSocket frame. +fn is_valid_close_status_code(status_code: u16) -> bool { + (1000..=4999).contains(&status_code) && !matches!(status_code, 1005 | 1006 | 1015) +} + #[derive(Debug)] pub enum WebDriverBiDiWebSocketFrameError { - /// The requested frame I/O deadline was zero or above the reviewed resource ceiling. InvalidFrameTimeout { - /// Rejected caller-supplied deadline. frame_timeout: Duration, - /// Maximum reviewed deadline accepted by this boundary. maximum_timeout: Duration, }, - /// The frame payload exceeded the reviewed memory ceiling. FrameTooLarge { - /// Rejected payload length in bytes. payload_bytes: usize, - /// Maximum payload length admitted by this boundary. maximum_bytes: usize, }, - /// Applying the operation-local nonblocking read mode failed. FrameReadModeConfigurationFailed { - /// Underlying operating-system error. source: io::Error, }, - /// A bounded socket read timed out before the frame was complete. FrameReadTimedOut { - /// Number of frame bytes consumed before timeout. bytes_read: usize, - /// Underlying operating-system error. source: io::Error, }, - /// A non-recoverable socket read failed before the frame was complete. FrameReadFailed { - /// Number of frame bytes consumed before failure. bytes_read: usize, - /// Underlying operating-system error. source: io::Error, }, - /// The peer ended the stream before the frame was complete. FrameEnded { - /// Number of frame bytes consumed before EOF. bytes_read: usize, }, - /// The frame header or RFC 6455 control-frame payload violated the protocol contract. MalformedFrame { - /// Stable, non-secret reason for rejection. reason: &'static str, }, - /// Applying the operation-local write timeout failed. FrameWriteModeConfigurationFailed { - /// Number of frame bytes already written before configuration failed. bytes_written: usize, - /// Underlying operating-system error. source: io::Error, }, - /// A bounded socket write timed out before the frame was complete. FrameWriteTimedOut { - /// Number of frame bytes written before timeout. bytes_written: usize, - /// Underlying operating-system error. source: io::Error, }, - /// A non-recoverable socket write failed before the frame was complete. FrameWriteFailed { - /// Number of frame bytes written before failure. bytes_written: usize, - /// Underlying operating-system error. source: io::Error, }, - /// The stream reported zero progress before the frame was complete. FrameWriteZero { - /// Number of frame bytes written before zero progress. bytes_written: usize, }, - /// Clearing the temporary write timeout failed before handoff. FrameWriteCleanupFailed { - /// Underlying operating-system error. source: io::Error, }, } @@ -619,41 +527,18 @@ pub enum WebDriverBiDiWebSocketFrameError { impl fmt::Display for WebDriverBiDiWebSocketFrameError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidFrameTimeout { .. } => formatter - .write_str("WebDriver BiDi WebSocket frame timeout is outside the reviewed bound"), - Self::FrameTooLarge { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame payload exceeded its bound") - } - Self::FrameReadModeConfigurationFailed { .. } => { - formatter.write_str("failed to configure bounded WebSocket frame reads") - } - Self::FrameReadTimedOut { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame read timed out") - } - Self::FrameReadFailed { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame read failed") - } - Self::FrameEnded { .. } => { - formatter.write_str("WebDriver BiDi WebSocket peer ended the frame stream") - } - Self::MalformedFrame { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame was malformed") - } - Self::FrameWriteModeConfigurationFailed { .. } => { - formatter.write_str("failed to configure bounded WebSocket frame writes") - } - Self::FrameWriteTimedOut { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame write timed out") - } - Self::FrameWriteFailed { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame write failed") - } - Self::FrameWriteZero { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame write made no progress") - } - Self::FrameWriteCleanupFailed { .. } => { - formatter.write_str("failed to clear the WebDriver BiDi WebSocket frame timeout") - } + Self::InvalidFrameTimeout { .. } => formatter.write_str("WebDriver BiDi WebSocket frame timeout is outside the reviewed bound"), + Self::FrameTooLarge { .. } => formatter.write_str("WebDriver BiDi WebSocket frame payload exceeded its bound"), + Self::FrameReadModeConfigurationFailed { .. } => formatter.write_str("failed to configure bounded WebSocket frame reads"), + Self::FrameReadTimedOut { .. } => formatter.write_str("WebDriver BiDi WebSocket frame read timed out"), + Self::FrameReadFailed { .. } => formatter.write_str("WebDriver BiDi WebSocket frame read failed"), + Self::FrameEnded { .. } => formatter.write_str("WebDriver BiDi WebSocket peer ended the frame stream"), + Self::MalformedFrame { .. } => formatter.write_str("WebDriver BiDi WebSocket frame was malformed"), + Self::FrameWriteModeConfigurationFailed { .. } => formatter.write_str("failed to configure bounded WebSocket frame writes"), + Self::FrameWriteTimedOut { .. } => formatter.write_str("WebDriver BiDi WebSocket frame write timed out"), + Self::FrameWriteFailed { .. } => formatter.write_str("WebDriver BiDi WebSocket frame write failed"), + Self::FrameWriteZero { .. } => formatter.write_str("WebDriver BiDi WebSocket frame write made no progress"), + Self::FrameWriteCleanupFailed { .. } => formatter.write_str("failed to clear the WebDriver BiDi WebSocket frame timeout"), } } } @@ -677,101 +562,33 @@ impl Error for WebDriverBiDiWebSocketFrameError { } } -/// Fail-closed errors while reading one bounded WebDriver BiDi WebSocket opening response. #[derive(Debug)] pub enum WebDriverBiDiWebSocketHandshakeResponseError { - /// The requested total response deadline was zero or above the reviewed resource ceiling. - InvalidResponseTimeout { - /// Rejected caller-supplied deadline. - response_timeout: Duration, - /// Maximum reviewed deadline accepted by this boundary. - maximum_timeout: Duration, - }, - /// The monotonic total response deadline elapsed before validation completed. - ResponseDeadlineExceeded { - /// Number of response bytes consumed before the deadline elapsed. - bytes_read: usize, - }, - /// The response exceeded the reviewed header-size ceiling before its terminator was found. - ResponseTooLarge { - /// Number of response bytes consumed before rejection. - bytes_read: usize, - /// Maximum response bytes admitted by this boundary. - maximum_bytes: usize, - }, - /// Applying the operation-local nonblocking read mode failed. - ResponseReadModeConfigurationFailed { - /// Number of response bytes consumed before configuration failed. - bytes_read: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A bounded socket read timed out before the opening response was complete. - ResponseReadTimedOut { - /// Number of response bytes consumed before the timed-out operation. - bytes_read: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A non-recoverable socket read failed before the opening response was complete. - ResponseReadFailed { - /// Number of response bytes consumed before the failure. - bytes_read: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// The peer closed the stream before sending a complete HTTP header block. - ResponseEndedBeforeHeaders { - /// Number of response bytes consumed before the peer closed the stream. - bytes_read: usize, - }, - /// The HTTP response was not a valid, required WebSocket opening response. - MalformedResponse { - /// Stable, non-secret reason for the rejected response shape. - reason: &'static str, - }, - /// The response's `Sec-WebSocket-Accept` did not correlate with the sent client key. + InvalidResponseTimeout { response_timeout: Duration, maximum_timeout: Duration }, + ResponseDeadlineExceeded { bytes_read: usize }, + ResponseTooLarge { bytes_read: usize, maximum_bytes: usize }, + ResponseReadModeConfigurationFailed { bytes_read: usize, source: io::Error }, + ResponseReadTimedOut { bytes_read: usize, source: io::Error }, + ResponseReadFailed { bytes_read: usize, source: io::Error }, + ResponseEndedBeforeHeaders { bytes_read: usize }, + MalformedResponse { reason: &'static str }, AcceptMismatch, - /// Restoring blocking mode failed after validation. - ReadModeCleanupFailed { - /// Underlying operating-system error. - source: io::Error, - }, + ReadModeCleanupFailed { source: io::Error }, } impl fmt::Display for WebDriverBiDiWebSocketHandshakeResponseError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidResponseTimeout { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response timeout is outside the reviewed bound", - ), - Self::ResponseDeadlineExceeded { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response exceeded its monotonic deadline", - ), - Self::ResponseTooLarge { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response exceeded its bounded header size", - ), - Self::ResponseReadModeConfigurationFailed { .. } => formatter.write_str( - "failed to configure bounded nonblocking WebDriver BiDi WebSocket response reads", - ), - Self::ResponseReadTimedOut { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response timed out before completion", - ), - Self::ResponseReadFailed { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response read failed before completion", - ), - Self::ResponseEndedBeforeHeaders { .. } => formatter.write_str( - "WebDriver BiDi WebSocket peer ended the stream before completing response headers", - ), - Self::MalformedResponse { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response was malformed or missing a required header", - ), - Self::AcceptMismatch => formatter.write_str( - "WebDriver BiDi WebSocket opening response accept value did not match the client key", - ), - Self::ReadModeCleanupFailed { .. } => formatter.write_str( - "failed to restore blocking WebDriver BiDi WebSocket response reads before handoff", - ), + Self::InvalidResponseTimeout { .. } => formatter.write_str("WebDriver BiDi WebSocket opening response timeout is outside the reviewed bound"), + Self::ResponseDeadlineExceeded { .. } => formatter.write_str("WebDriver BiDi WebSocket opening response exceeded its monotonic deadline"), + Self::ResponseTooLarge { .. } => formatter.write_str("WebDriver BiDi WebSocket opening response exceeded its bounded header size"), + Self::ResponseReadModeConfigurationFailed { .. } => formatter.write_str("failed to configure bounded nonblocking WebDriver BiDi WebSocket response reads"), + Self::ResponseReadTimedOut { .. } => formatter.write_str("WebDriver BiDi WebSocket opening response timed out before completion"), + Self::ResponseReadFailed { .. } => formatter.write_str("WebDriver BiDi WebSocket opening response read failed before completion"), + Self::ResponseEndedBeforeHeaders { .. } => formatter.write_str("WebDriver BiDi WebSocket peer ended the stream before completing response headers"), + Self::MalformedResponse { .. } => formatter.write_str("WebDriver BiDi WebSocket opening response was malformed or missing a required header"), + Self::AcceptMismatch => formatter.write_str("WebDriver BiDi WebSocket opening response accept value did not match the client key"), + Self::ReadModeCleanupFailed { .. } => formatter.write_str("failed to restore blocking WebDriver BiDi WebSocket response reads before handoff"), } } } @@ -783,20 +600,12 @@ impl Error for WebDriverBiDiWebSocketHandshakeResponseError { | Self::ResponseReadTimedOut { source, .. } | Self::ResponseReadFailed { source, .. } | Self::ReadModeCleanupFailed { source } => Some(source), - Self::InvalidResponseTimeout { .. } - | Self::ResponseDeadlineExceeded { .. } - | Self::ResponseTooLarge { .. } - | Self::ResponseEndedBeforeHeaders { .. } - | Self::MalformedResponse { .. } - | Self::AcceptMismatch => None, + _ => None, } } } -struct ParsedOpeningResponse { - status_code: u16, - byte_count: usize, -} +struct ParsedOpeningResponse { status_code: u16, byte_count: usize } fn expected_accept_value(client_key: &WebDriverBiDiWebSocketClientKey) -> String { let mut digest = Sha1::new(); @@ -806,1669 +615,153 @@ fn expected_accept_value(client_key: &WebDriverBiDiWebSocketClientKey) -> String } fn is_http_token_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() - || matches!( - byte, - b'!' | b'#' - | b'$' - | b'%' - | b'&' - | b'\'' - | b'*' - | b'+' - | b'-' - | b'.' - | b'^' - | b'_' - | b'`' - | b'|' - | b'~' - ) + byte.is_ascii_alphanumeric() || matches!(byte, b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~') } fn has_header_token(value: &str, expected: &str) -> bool { - value - .split(',') - .map(str::trim) - .any(|token| token.eq_ignore_ascii_case(expected)) + value.split(',').map(str::trim).any(|token| token.eq_ignore_ascii_case(expected)) } #[allow(clippy::collapsible_if)] -fn parse_opening_response( - response: &[u8], - client_key: &WebDriverBiDiWebSocketClientKey, -) -> Result { +fn parse_opening_response(response: &[u8], client_key: &WebDriverBiDiWebSocketClientKey) -> Result { if !response.ends_with(b"\r\n\r\n") { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response is missing its CRLF header terminator", - }, - ); - } - let response_text = std::str::from_utf8(response).map_err(|_| { - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response headers are not valid UTF-8", - } - })?; - let header_text = &response_text[..response_text.len() - 4]; - let (status_line, header_lines) = header_text - .split_once("\r\n") - .map_or((header_text, ""), |(line, rest)| (line, rest)); - if status_line.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "status line contains a control byte", - }, - ); - } - let status_code = status_line - .strip_prefix("HTTP/1.1 ") - .and_then(|rest| rest.split_whitespace().next()) - .and_then(|value| value.parse::().ok()); - if status_code != Some(101) { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "status line is not HTTP/1.1 101", - }, - ); - } - + return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "response is missing its CRLF header terminator" }); + } + let response_text = std::str::from_utf8(response).map_err(|_| WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "response headers are not valid UTF-8" })?; + let header_text = &response_text[..response_text.len()-4]; + let (status_line, header_lines) = header_text.split_once("\r\n").map_or((header_text, ""), |(line, rest)| (line, rest)); + if status_line.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "status line contains a control byte" }); } + let status_code = status_line.strip_prefix("HTTP/1.1 ").and_then(|rest| rest.split_whitespace().next()).and_then(|value| value.parse::().ok()); + if status_code != Some(101) { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "status line is not HTTP/1.1 101" }); } let mut upgrade_has_websocket = false; let mut connection_has_upgrade = false; let mut accept = None; for line in header_lines.split("\r\n") { - if line.is_empty() - || line - .as_bytes() - .first() - .is_some_and(|byte| matches!(byte, b' ' | b'\t')) - { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "header line is empty or folded", - }, - ); - } - let (name, value) = line.split_once(':').ok_or( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "header line has no colon", - }, - )?; - if name.is_empty() || !name.bytes().all(is_http_token_byte) { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "header name is not an HTTP token", - }, - ); - } + if line.is_empty() || line.as_bytes().first().is_some_and(|byte| matches!(byte, b' ' | b'\t')) { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "header line is empty or folded" }); } + let (name, value) = line.split_once(':').ok_or(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "header line has no colon" })?; + if name.is_empty() || !name.bytes().all(is_http_token_byte) { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "header name is not an HTTP token" }); } let value = value.trim_matches([' ', '\t']); - if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "header value contains a control byte", - }, - ); - } - if name.eq_ignore_ascii_case("upgrade") { - upgrade_has_websocket |= has_header_token(value, "websocket"); - } else if name.eq_ignore_ascii_case("connection") { - connection_has_upgrade |= has_header_token(value, "upgrade"); - } else if name.eq_ignore_ascii_case("sec-websocket-accept") { - if accept.is_some() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response repeats the Sec-WebSocket-Accept header", - }, - ); - } - accept = Some(value); - } - } - - if !upgrade_has_websocket { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "Upgrade header does not contain websocket", - }, - ); - } - if !connection_has_upgrade { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "Connection header does not contain Upgrade", - }, - ); + if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "header value contains a control byte" }); } + if name.eq_ignore_ascii_case("upgrade") { upgrade_has_websocket |= has_header_token(value, "websocket"); } + else if name.eq_ignore_ascii_case("connection") { connection_has_upgrade |= has_header_token(value, "upgrade"); } + else if name.eq_ignore_ascii_case("sec-websocket-accept") { if accept.is_some() { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "response repeats the Sec-WebSocket-Accept header" }); } accept = Some(value); } } - let Some(accept) = accept else { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response has no Sec-WebSocket-Accept header", - }, - ); - }; - if accept != expected_accept_value(client_key) { - return Err(WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch); - } - - Ok(ParsedOpeningResponse { - status_code: 101, - byte_count: response.len(), - }) + if !upgrade_has_websocket { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "Upgrade header does not contain websocket" }); } + if !connection_has_upgrade { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "Connection header does not contain Upgrade" }); } + let Some(accept) = accept else { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "response has no Sec-WebSocket-Accept header" }); }; + if accept != expected_accept_value(client_key) { return Err(WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch); } + Ok(ParsedOpeningResponse { status_code: 101, byte_count: response.len() }) } trait OpeningResponseReader { fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()>; fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result; } - impl OpeningResponseReader for TcpStream { - fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { - TcpStream::set_nonblocking(self, nonblocking) - } - - fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { - self.read(bytes) - } + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { TcpStream::set_nonblocking(self, nonblocking) } + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { self.read(bytes) } } fn serialize_text_frame(payload: &[u8], masking_key: WebDriverBiDiWebSocketMaskKey) -> Vec { - let mut frame = Vec::with_capacity(payload.len() + 14); + let mut frame = Vec::with_capacity(payload.len()+14); frame.push(0x81); match payload.len() { 0..=125 => frame.push(0x80 | payload.len() as u8), - 126..=65_535 => { - frame.push(0x80 | 126); - frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - } - length => { - frame.push(0x80 | 127); - frame.extend_from_slice(&(length as u64).to_be_bytes()); - } + 126..=65_535 => { frame.push(0x80 | 126); frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); } + length => { frame.push(0x80 | 127); frame.extend_from_slice(&(length as u64).to_be_bytes()); } } frame.extend_from_slice(masking_key.as_bytes()); - frame.extend( - payload.iter().enumerate().map(|(index, byte)| { - byte ^ masking_key.as_bytes()[index % masking_key.as_bytes().len()] - }), - ); + frame.extend(payload.iter().enumerate().map(|(index, byte)| byte ^ masking_key.as_bytes()[index % masking_key.as_bytes().len()])); frame } -trait FrameWriter { - fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; - fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result; -} +trait FrameWriter { fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result; } +impl FrameWriter for TcpStream { fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { TcpStream::set_write_timeout(self, timeout) } fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { self.write(bytes) } } -impl FrameWriter for TcpStream { - fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { - TcpStream::set_write_timeout(self, timeout) - } - - fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { - self.write(bytes) - } -} - -fn write_frame_with_clock( - writer: &mut dyn FrameWriter, - frame: &[u8], - frame_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result { - let deadline = now() + frame_timeout; +fn write_frame_with_clock(writer: &mut dyn FrameWriter, frame: &[u8], frame_timeout: Duration, now: &mut dyn FnMut() -> Instant) -> Result { + let deadline = now()+frame_timeout; let mut bytes_written = 0; while bytes_written < frame.len() { let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { - bytes_written, - source: io::Error::new(io::ErrorKind::TimedOut, "frame write deadline elapsed"), - }); - } - writer - .set_write_timeout(Some(remaining)) - .map_err(|source| { - WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { - bytes_written, - source, - } - })?; + if remaining.is_zero() { return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { bytes_written, source: io::Error::new(io::ErrorKind::TimedOut, "frame write deadline elapsed") }); } + writer.set_write_timeout(Some(remaining)).map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { bytes_written, source })?; match writer.write_frame_bytes(&frame[bytes_written..]) { - Ok(0) => { - return Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written }); - } + Ok(0) => return Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written }), Ok(written) => bytes_written += written, - Err(source) => { - if source.kind() == io::ErrorKind::Interrupted { - continue; - } - if matches!( - source.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) { - if deadline.saturating_duration_since(now()).is_zero() { - return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { - bytes_written, - source, - }); - } - thread::sleep(Duration::from_millis(1)); - continue; - } - return Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { - bytes_written, - source, - }); - } + Err(source) if source.kind()==io::ErrorKind::Interrupted => continue, + Err(source) if matches!(source.kind(), io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock) => { if deadline.saturating_duration_since(now()).is_zero() { return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { bytes_written, source }); } thread::sleep(Duration::from_millis(1)); continue; }, + Err(source) => return Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { bytes_written, source }), } } - writer - .set_write_timeout(None) - .map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { source })?; + writer.set_write_timeout(None).map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { source })?; Ok(bytes_written) } -fn read_frame_with_clock( - reader: &mut dyn OpeningResponseReader, - frame_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result { - let deadline = now() + frame_timeout; - reader.set_nonblocking(true).map_err(|source| { - WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { source } - })?; - let mut bytes_read = 0; - let mut header = [0_u8; 2]; - read_frame_bytes_with_clock(reader, &mut header, &mut bytes_read, deadline, now)?; - let first = header[0]; - let second = header[1]; - if first & 0x70 != 0 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "reserved frame bits are not negotiated", - }); - } - let fin = first & 0x80 != 0; - let opcode = first & 0x0f; - match opcode { - 0x0..=0x2 => {} - 0x8..=0xa => { - if !fin { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "control frames must not be fragmented", - }); - } - } - _ => { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "frame opcode is reserved or unsupported", - }); - } - } - if second & 0x80 != 0 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "server-to-client frames must not be masked", - }); - } - let length_code = second & 0x7f; - let payload_length = match length_code { +fn read_frame_with_clock(reader: &mut dyn OpeningResponseReader, frame_timeout: Duration, now: &mut dyn FnMut() -> Instant) -> Result { + let deadline = now()+frame_timeout; + reader.set_nonblocking(true).map_err(|source| WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { source })?; + let mut bytes_read=0; + let mut header=[0_u8;2]; + read_frame_bytes_with_clock(reader,&mut header,&mut bytes_read,deadline,now)?; + let first=header[0]; let second=header[1]; + if first & 0x70 != 0 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "reserved frame bits are not negotiated" }); } + let fin=first & 0x80 !=0; let opcode=first & 0x0f; + match opcode { 0x0..=0x2 => {}, 0x8..=0xa => { if !fin { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "control frames must not be fragmented" }); } }, _ => return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "frame opcode is reserved or unsupported" }) } + if second & 0x80 != 0 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "server-to-client frames must not be masked" }); } + let length_code=second & 0x7f; + let payload_length=match length_code { 0..=125 => u64::from(length_code), - 126 => { - let mut extended = [0_u8; 2]; - read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; - let length = u64::from(u16::from_be_bytes(extended)); - if length < 126 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "frame length encoding is not minimal", - }); - } - length - } - _ => { - let mut extended = [0_u8; 8]; - read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; - if extended[0] & 0x80 != 0 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "frame length uses the reserved high bit", - }); - } - let length = u64::from_be_bytes(extended); - if length < 65_536 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "frame length encoding is not minimal", - }); - } - length - } + 126 => { let mut extended=[0_u8;2]; read_frame_bytes_with_clock(reader,&mut extended,&mut bytes_read,deadline,now)?; let length=u64::from(u16::from_be_bytes(extended)); if length<126 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "frame length encoding is not minimal" }); } length }, + _ => { let mut extended=[0_u8;8]; read_frame_bytes_with_clock(reader,&mut extended,&mut bytes_read,deadline,now)?; if extended[0]&0x80!=0 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "frame length uses the reserved high bit" }); } let length=u64::from_be_bytes(extended); if length<65_536 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "frame length encoding is not minimal" }); } length } }; - if payload_length > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64 { - return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { - payload_bytes: payload_length.min(usize::MAX as u64) as usize, - maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES, - }); - } - if opcode >= 0x8 && payload_length > 125 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "control frame payload exceeds 125 bytes", - }); - } - let payload_length = payload_length as usize; - let mut payload = vec![0_u8; payload_length]; - read_frame_bytes_with_clock(reader, &mut payload, &mut bytes_read, deadline, now)?; - if opcode == 0x8 { - if payload.len() == 1 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "Close frame payload must be empty or begin with a two-byte status code", - }); - } - if payload.len() > 1 && std::str::from_utf8(&payload[2..]).is_err() { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "Close frame reason is not valid UTF-8", - }); - } - } - reader.set_nonblocking(false).map_err(|source| { - WebDriverBiDiWebSocketFrameError::FrameReadFailed { bytes_read, source } - })?; - Ok(WebDriverBiDiWebSocketFrame { - fin, - opcode, - payload, - }) -} - -fn read_frame_bytes_with_clock( - reader: &mut dyn OpeningResponseReader, - destination: &mut [u8], - bytes_read: &mut usize, - deadline: Instant, - now: &mut dyn FnMut() -> Instant, -) -> Result<(), WebDriverBiDiWebSocketFrameError> { - let mut offset = 0; - while offset < destination.len() { - let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { - bytes_read: *bytes_read, - source: io::Error::new(io::ErrorKind::TimedOut, "frame read deadline elapsed"), - }); - } - match reader.read_response_bytes(&mut destination[offset..]) { - Ok(0) => { - return Err(WebDriverBiDiWebSocketFrameError::FrameEnded { - bytes_read: *bytes_read, - }); - } - Ok(read) if read > destination.len() - offset => { - return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { - bytes_read: *bytes_read, - source: io::Error::new( - io::ErrorKind::InvalidData, - "frame reader returned more bytes than requested", - ), - }); - } - Ok(read) => { - offset += read; - *bytes_read += read; - } - Err(source) if source.kind() == io::ErrorKind::Interrupted => {} - Err(source) - if matches!( - source.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) => - { - if deadline.saturating_duration_since(now()).is_zero() { - return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { - bytes_read: *bytes_read, - source, - }); - } - thread::sleep(Duration::from_millis(1)); - } - Err(source) => { - return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { - bytes_read: *bytes_read, - source, - }); - } - } - } + if payload_length > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64 { return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { payload_bytes: payload_length.min(usize::MAX as u64) as usize, maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES }); } + if opcode>=0x8 && payload_length>125 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "control frame payload exceeds 125 bytes" }); } + let payload_length=payload_length as usize; + let mut payload=vec![0_u8;payload_length]; + read_frame_bytes_with_clock(reader,&mut payload,&mut bytes_read,deadline,now)?; + if opcode==0x8 { + if payload.len()==1 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "Close frame payload must be empty or begin with a two-byte status code" }); } + if payload.len()>1 { + let status_code=u16::from_be_bytes([payload[0],payload[1]]); + if !is_valid_close_status_code(status_code) { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "Close frame status code is not valid on the wire" }); } + if std::str::from_utf8(&payload[2..]).is_err() { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "Close frame reason is not valid UTF-8" }); } + } + } + reader.set_nonblocking(false).map_err(|source| WebDriverBiDiWebSocketFrameError::FrameReadFailed { bytes_read, source })?; + Ok(WebDriverBiDiWebSocketFrame { fin, opcode, payload }) +} + +fn read_frame_bytes_with_clock(reader:&mut dyn OpeningResponseReader,destination:&mut [u8],bytes_read:&mut usize,deadline:Instant,now:&mut dyn FnMut()->Instant)->Result<(),WebDriverBiDiWebSocketFrameError>{ + let mut offset=0; + while offsetreturn Err(WebDriverBiDiWebSocketFrameError::FrameEnded{bytes_read:*bytes_read}),Ok(read) if read>destination.len()-offset=>return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed{bytes_read:*bytes_read,source:io::Error::new(io::ErrorKind::InvalidData,"frame reader returned more bytes than requested")}),Ok(read)=>{offset+=read;*bytes_read+=read;},Err(source) if source.kind()==io::ErrorKind::Interrupted=>{},Err(source) if matches!(source.kind(),io::ErrorKind::TimedOut|io::ErrorKind::WouldBlock)=>{if deadline.saturating_duration_since(now()).is_zero(){return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut{bytes_read:*bytes_read,source});} thread::sleep(Duration::from_millis(1));},Err(source)=>return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed{bytes_read:*bytes_read,source})}} Ok(()) } -fn read_opening_response_with_clock( - reader: &mut dyn OpeningResponseReader, - client_key: &WebDriverBiDiWebSocketClientKey, - response_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { - let deadline = now() + response_timeout; - let mut response = Vec::new(); - - reader.set_nonblocking(true).map_err(|source| { - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { - bytes_read: 0, - source, - } - })?; - - loop { - let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { - bytes_read: response.len(), - }, - ); - } - if response.len() >= MAX_WEBSOCKET_OPENING_RESPONSE_BYTES { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { - bytes_read: response.len(), - maximum_bytes: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, - }, - ); - } - let mut byte = [0_u8; 1]; - match reader.read_response_bytes(&mut byte) { - Ok(0) => { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { - bytes_read: response.len(), - }, - ); - } - Ok(1) => { - response.push(byte[0]); - if response.ends_with(b"\r\n\r\n") { - if deadline.saturating_duration_since(now()).is_zero() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { - bytes_read: response.len(), - }, - ); - } - let parsed = parse_opening_response(&response, client_key)?; - reader.set_nonblocking(false).map_err(|source| { - WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { - source, - } - })?; - return Ok((parsed.status_code, parsed.byte_count)); - } - } - Ok(_) => { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { - bytes_read: response.len(), - source: io::Error::new( - io::ErrorKind::InvalidData, - "response reader returned more bytes than requested", - ), - }, - ); - } - Err(source) if source.kind() == io::ErrorKind::Interrupted => {} - Err(source) - if matches!( - source.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) => - { - if deadline.saturating_duration_since(now()).is_zero() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { - bytes_read: response.len(), - source, - }, - ); - } - thread::sleep(Duration::from_millis(1)); - } - Err(source) => { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { - bytes_read: response.len(), - source, - }, - ); - } - } - } +fn read_opening_response_with_clock(reader:&mut dyn OpeningResponseReader,client_key:&WebDriverBiDiWebSocketClientKey,response_timeout:Duration,now:&mut dyn FnMut()->Instant)->Result<(u16,usize),WebDriverBiDiWebSocketHandshakeResponseError>{ + let deadline=now()+response_timeout; let mut response=Vec::new(); reader.set_nonblocking(true).map_err(|source|WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed{bytes_read:0,source})?; + loop { let remaining=deadline.saturating_duration_since(now()); if remaining.is_zero(){return Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded{bytes_read:response.len()});} if response.len()>=MAX_WEBSOCKET_OPENING_RESPONSE_BYTES{return Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge{bytes_read:response.len(),maximum_bytes:MAX_WEBSOCKET_OPENING_RESPONSE_BYTES});} let mut byte=[0_u8;1]; match reader.read_response_bytes(&mut byte){Ok(0)=>return Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders{bytes_read:response.len()}),Ok(1)=>{response.push(byte[0]);if response.ends_with(b"\r\n\r\n"){if deadline.saturating_duration_since(now()).is_zero(){return Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded{bytes_read:response.len()});}let parsed=parse_opening_response(&response,client_key)?;reader.set_nonblocking(false).map_err(|source|WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed{source})?;return Ok((parsed.status_code,parsed.byte_count));}},Ok(_)=>return Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed{bytes_read:response.len(),source:io::Error::new(io::ErrorKind::InvalidData,"response reader returned more bytes than requested")}),Err(source) if source.kind()==io::ErrorKind::Interrupted=>{},Err(source) if matches!(source.kind(),io::ErrorKind::TimedOut|io::ErrorKind::WouldBlock)=>{if deadline.saturating_duration_since(now()).is_zero(){return Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut{bytes_read:response.len(),source});}thread::sleep(Duration::from_millis(1));},Err(source)=>return Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed{bytes_read:response.len(),source})}} } -/// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. #[derive(Debug)] -pub enum WebDriverBiDiWebSocketOpeningWriteError { - /// The requested total write deadline was zero or above the reviewed resource ceiling. - InvalidWriteTimeout { - /// Rejected caller-supplied deadline. - write_timeout: Duration, - /// Maximum reviewed deadline accepted by this boundary. - maximum_timeout: Duration, - }, - /// The monotonic total write deadline elapsed before the complete request was written. - WriteDeadlineExceeded { - /// Number of request bytes written before the deadline elapsed. - bytes_written: usize, - }, - /// Applying the remaining operating-system write timeout failed. - WriteTimeoutConfigurationFailed { - /// Number of request bytes already written before configuration failed. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A bounded socket write reported timeout or would-block before completion. - WriteTimedOut { - /// Number of request bytes written before the timed-out operation. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A socket write returned zero bytes before the request was complete. - WriteZero { - /// Number of request bytes written before the zero-length write. - bytes_written: usize, - }, - /// A non-recoverable socket write failed before the complete request was emitted. - WriteFailed { - /// Number of request bytes written before the failure. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// Clearing the operation-local socket write timeout failed after all request bytes were sent. - WriteTimeoutCleanupFailed { - /// Number of request bytes already written before cleanup failed. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, -} +pub enum WebDriverBiDiWebSocketOpeningWriteError { InvalidWriteTimeout{write_timeout:Duration,maximum_timeout:Duration},WriteDeadlineExceeded{bytes_written:usize},WriteTimeoutConfigurationFailed{bytes_written:usize,source:io::Error},WriteTimedOut{bytes_written:usize,source:io::Error},WriteZero{bytes_written:usize},WriteFailed{bytes_written:usize,source:io::Error},WriteTimeoutCleanupFailed{bytes_written:usize,source:io::Error} } +impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError{fn fmt(&self,formatter:&mut fmt::Formatter<'_>)->fmt::Result{match self{Self::InvalidWriteTimeout{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound"),Self::WriteDeadlineExceeded{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write exceeded its monotonic deadline"),Self::WriteTimeoutConfigurationFailed{..}=>formatter.write_str("failed to configure the bounded WebDriver BiDi WebSocket opening write timeout"),Self::WriteTimedOut{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write timed out before the request was complete"),Self::WriteZero{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write returned zero before the request was complete"),Self::WriteFailed{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write failed before the request was complete"),Self::WriteTimeoutCleanupFailed{..}=>formatter.write_str("failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff")}}} +impl Error for WebDriverBiDiWebSocketOpeningWriteError{fn source(&self)->Option<&(dyn Error+'static)>{match self{Self::WriteTimeoutConfigurationFailed{source,..}|Self::WriteTimedOut{source,..}|Self::WriteFailed{source,..}|Self::WriteTimeoutCleanupFailed{source,..}=>Some(source),_=>None}}} -impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidWriteTimeout { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound", - ), - Self::WriteDeadlineExceeded { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write exceeded its monotonic deadline", - ), - Self::WriteTimeoutConfigurationFailed { .. } => formatter.write_str( - "failed to configure the bounded WebDriver BiDi WebSocket opening write timeout", - ), - Self::WriteTimedOut { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write timed out before the request was complete", - ), - Self::WriteZero { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write returned zero before the request was complete", - ), - Self::WriteFailed { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write failed before the request was complete", - ), - Self::WriteTimeoutCleanupFailed { .. } => formatter.write_str( - "failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff", - ), - } - } -} - -impl Error for WebDriverBiDiWebSocketOpeningWriteError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::WriteTimeoutConfigurationFailed { source, .. } - | Self::WriteTimedOut { source, .. } - | Self::WriteFailed { source, .. } - | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), - Self::InvalidWriteTimeout { .. } - | Self::WriteDeadlineExceeded { .. } - | Self::WriteZero { .. } => None, - } - } -} - -trait OpeningRequestWriter { - fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; - fn clear_write_timeout(&self) -> io::Result<()>; - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; -} - -impl OpeningRequestWriter for TcpStream { - fn set_write_timeout(&self, timeout: Duration) -> io::Result<()> { - TcpStream::set_write_timeout(self, Some(timeout)) - } - - fn clear_write_timeout(&self) -> io::Result<()> { - TcpStream::set_write_timeout(self, None) - } - - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { - self.write(bytes) - } -} - -fn write_request_with_clock( - writer: &mut dyn OpeningRequestWriter, - request: &[u8], - write_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result { - let deadline = now() + write_timeout; - let mut bytes_written = 0; - - while bytes_written < request.len() { - let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, - ); - } - writer.set_write_timeout(remaining).map_err(|source| { - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written, - source, - } - })?; - - match writer.write_request_bytes(&request[bytes_written..]) { - Ok(0) => { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); - } - Ok(count) => { - bytes_written += count; - if deadline.saturating_duration_since(now()).is_zero() { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written, - }, - ); - } - } - Err(source) => { - if source.kind() == io::ErrorKind::Interrupted { - continue; - } - if matches!( - source.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written, - source, - }); - } - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written, - source, - }); - } - } - } - - writer.clear_write_timeout().map_err(|source| { - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written, - source, - } - })?; - - Ok(bytes_written) -} +trait OpeningRequestWriter{fn set_write_timeout(&self,timeout:Duration)->io::Result<()>;fn clear_write_timeout(&self)->io::Result<()>;fn write_request_bytes(&mut self,bytes:&[u8])->io::Result;} +impl OpeningRequestWriter for TcpStream{fn set_write_timeout(&self,timeout:Duration)->io::Result<()>{TcpStream::set_write_timeout(self,Some(timeout))}fn clear_write_timeout(&self)->io::Result<()>{TcpStream::set_write_timeout(self,None)}fn write_request_bytes(&mut self,bytes:&[u8])->io::Result{self.write(bytes)}} +fn write_request_with_clock(writer:&mut dyn OpeningRequestWriter,request:&[u8],write_timeout:Duration,now:&mut dyn FnMut()->Instant)->Result{let deadline=now()+write_timeout;let mut bytes_written=0;while bytes_writtenreturn Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero{bytes_written}),Ok(count)=>{bytes_written+=count;if deadline.saturating_duration_since(now()).is_zero(){return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded{bytes_written});}},Err(source) if source.kind()==io::ErrorKind::Interrupted=>continue,Err(source) if matches!(source.kind(),io::ErrorKind::TimedOut|io::ErrorKind::WouldBlock)=>return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut{bytes_written,source}),Err(source)=>return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed{bytes_written,source})}}writer.clear_write_timeout().map_err(|source|WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed{bytes_written,source})?;Ok(bytes_written)} #[cfg(test)] -#[allow(clippy::expect_used)] mod opening_write_tests { use super::*; - use std::{ - collections::VecDeque, - net::{Shutdown, TcpListener}, - thread, - }; - - use originweave_core::WebDriverBiDiWebSocketEndpoint; - - #[derive(Debug)] - enum WriteAction { - Count(usize), - Error(io::ErrorKind), - } - - #[derive(Debug)] - struct FakeWriter { - timeout_error: Option, - clear_timeout_error: Option, - actions: VecDeque, - } - - impl FakeWriter { - fn new(actions: impl IntoIterator) -> Self { - Self { - timeout_error: None, - clear_timeout_error: None, - actions: actions.into_iter().collect(), - } - } - } - - impl OpeningRequestWriter for FakeWriter { - fn set_write_timeout(&self, _timeout: Duration) -> io::Result<()> { - if let Some(kind) = self.timeout_error { - return Err(io::Error::from(kind)); - } - Ok(()) - } - - fn clear_write_timeout(&self) -> io::Result<()> { - if let Some(kind) = self.clear_timeout_error { - return Err(io::Error::from(kind)); - } - Ok(()) - } - - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { - let action = self - .actions - .pop_front() - .unwrap_or(WriteAction::Count(bytes.len())); - match action { - WriteAction::Count(count) => Ok(count.min(bytes.len())), - WriteAction::Error(kind) => Err(io::Error::from(kind)), - } - } - } - - impl FrameWriter for FakeWriter { - fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { - let error = if timeout.is_some() { - self.timeout_error - } else { - self.clear_timeout_error - }; - error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) - } - - fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { - self.write_request_bytes(bytes) - } - } - - #[derive(Clone, Debug)] - enum ReadAction { - Byte(u8), - Count(usize), - End, - Error(io::ErrorKind), - } - - #[derive(Debug)] - struct FakeReader { - actions: VecDeque, - mode_error: Option, - cleanup_error: Option, - } - - impl FakeReader { - fn new(actions: impl IntoIterator) -> Self { - Self { - actions: actions.into_iter().collect(), - mode_error: None, - cleanup_error: None, - } - } - } - - impl OpeningResponseReader for FakeReader { - fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { - let error = if nonblocking { - self.mode_error - } else { - self.cleanup_error - }; - error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) - } - - fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { - match self.actions.pop_front().unwrap_or(ReadAction::End) { - ReadAction::Byte(byte) => { - bytes[0] = byte; - Ok(1) - } - ReadAction::Count(count) => Ok(count), - ReadAction::End => Ok(0), - ReadAction::Error(kind) => Err(io::Error::from(kind)), - } - } - } - - fn client_key() -> WebDriverBiDiWebSocketClientKey { - WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ==") - .expect("test client key must be valid") - } - - fn valid_response() -> Vec { - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec() - } - - fn byte_actions(bytes: &[u8]) -> Vec { - bytes.iter().copied().map(ReadAction::Byte).collect() - } - - fn is_malformed_response(response: &[u8], key: &WebDriverBiDiWebSocketClientKey) -> bool { - matches!( - parse_opening_response(response, key), - Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { .. }) - ) - } - - fn read_with_fake( - reader: &mut FakeReader, - now_values: impl IntoIterator, - ) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { - let key = client_key(); - let fallback = Instant::now(); - let mut now_values = now_values.into_iter(); - let mut now = || now_values.next().unwrap_or(fallback); - read_opening_response_with_clock(reader, &key, Duration::from_secs(1), &mut now) - } - - fn read_frame_with_fake( - reader: &mut FakeReader, - now_values: impl IntoIterator, - ) -> Result { - let fallback = Instant::now(); - let mut now_values = now_values.into_iter(); - let mut now = || now_values.next().unwrap_or(fallback); - read_frame_with_clock(reader, Duration::from_secs(1), &mut now) - } - - #[test] - fn parser_accepts_case_insensitive_upgrade_tokens_and_rejects_malformed_headers() { - let key = client_key(); - let response = b"HTTP/1.1 101 Switching Protocols\r\nUpGrAdE: WebSocket\r\nConnection: keep-alive, Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nX-Test: retained\r\n\r\n"; - let parsed = parse_opening_response(response, &key).expect("valid response"); - assert_eq!(parsed.status_code, 101); - assert_eq!(parsed.byte_count, response.len()); - assert!(!is_malformed_response(response, &key)); - let same_length_mismatch = String::from_utf8(response.to_vec()) - .expect("valid response fixture") - .replace( - "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", - "s3pPLMBiTxaQ9kYGzzhZRbK+xOoX", - ); - assert!(parse_opening_response(same_length_mismatch.as_bytes(), &key).is_err()); - - let malformed_responses = [ - b"HTTP/1.1 101".to_vec(), - vec![0xff, b'\r', b'\n', b'\r', b'\n'], - b"HTTP/1.1 101\0 Switching Protocols\r\n\r\n".to_vec(), - b"HTTP/1.1 200 OK\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nBad Header: value\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\n: value\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: web\x01socket\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nUpgrade: websocket\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nConnection: Upgrade\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nSec-WebSocket-Accept: one\r\nSec-WebSocket-Accept: two\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: h2c\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: keep-alive\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n".to_vec(), - ]; - for response in malformed_responses { - assert!(is_malformed_response(&response, &key)); - } - } - - #[test] - fn bounded_response_reader_covers_deadlines_size_io_and_cleanup() { - let start = Instant::now(); - - let mut valid_reader = FakeReader::new(byte_actions(&valid_response())); - let valid = read_with_fake(&mut valid_reader, [start]); - assert!(valid.is_ok()); - - let mut malformed_reader = FakeReader::new(byte_actions(b"HTTP/1.1 200 OK\r\n\r\n")); - assert!(read_with_fake(&mut malformed_reader, [start]).is_err()); - - let mut interrupted_reader = FakeReader::new( - std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) - .chain(byte_actions(&valid_response())), - ); - assert!(read_with_fake(&mut interrupted_reader, [start]).is_ok()); - - let mut mode_error_reader = FakeReader::new([]); - mode_error_reader.mode_error = Some(io::ErrorKind::InvalidInput); - assert!(read_with_fake(&mut mode_error_reader, [start]).is_err()); - - let mut ended_reader = FakeReader::new([ReadAction::End]); - assert!(read_with_fake(&mut ended_reader, [start]).is_err()); - - let mut count_reader = FakeReader::new([ReadAction::Count(2)]); - assert!(read_with_fake(&mut count_reader, [start]).is_err()); - - let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); - assert!(read_with_fake(&mut failed_reader, [start]).is_err()); - - let mut retrying_reader = FakeReader::new( - std::iter::once(ReadAction::Error(io::ErrorKind::WouldBlock)) - .chain(byte_actions(&valid_response())), - ); - assert!(read_with_fake(&mut retrying_reader, [start]).is_ok()); - - let mut timed_out_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::TimedOut)]); - assert!( - read_with_fake( - &mut timed_out_reader, - [start, start, start + Duration::from_secs(1)] - ) - .is_err() - ); - - let mut deadline_reader = FakeReader::new([ReadAction::End]); - assert!( - read_with_fake( - &mut deadline_reader, - [start, start + Duration::from_secs(1)] - ) - .is_err() - ); - - let mut late_response_reader = FakeReader::new(byte_actions(&valid_response())); - let mut late_response_times = vec![start; valid_response().len() + 1]; - late_response_times.push(start + Duration::from_secs(1)); - assert!(read_with_fake(&mut late_response_reader, late_response_times).is_err()); - - let mut cleanup_reader = FakeReader::new(byte_actions(&valid_response())); - cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); - assert!(read_with_fake(&mut cleanup_reader, [start]).is_err()); - - let mut too_large_reader = FakeReader::new(std::iter::repeat_n( - ReadAction::Byte(b'a'), - MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, - )); - assert!(read_with_fake(&mut too_large_reader, [start]).is_err()); - } - - #[test] - fn response_errors_have_deterministic_messages_and_sources() { - let source = io::Error::from(io::ErrorKind::InvalidInput); - let errors = [ - WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { - response_timeout: Duration::ZERO, - maximum_timeout: MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { - bytes_read: 1, - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { - bytes_read: 1, - maximum_bytes: 1, - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { - bytes_read: 1, - }, - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "test" }, - WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch, - WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { source }, - ]; - for (error, has_source) in errors.iter().zip([ - false, false, false, true, true, true, false, false, false, true, - ]) { - assert!(!error.to_string().is_empty()); - assert_eq!(error.source().is_some(), has_source); - } - } - - #[test] - fn bounded_writer_completes_partial_and_interrupted_writes() { - let mut writer = FakeWriter::new([ - WriteAction::Count(2), - WriteAction::Error(io::ErrorKind::Interrupted), - WriteAction::Count(3), - ]); - let start = Instant::now(); - let mut times = VecDeque::from([start, start, start, start]); - let mut now = || times.pop_front().unwrap_or(start); - let result = - write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); - let is_five = |candidate: Result| { - matches!(candidate, Ok(5)) - }; - assert!(is_five(result)); - assert!(!is_five(Ok(4))); - } - - fn join_loopback_server(server: thread::JoinHandle>) -> bool { - match server.join() { - Ok(result) => { - result.expect("loopback server must accept the client"); - false - } - Err(_) => true, - } - } - - #[test] - fn bounded_writer_clears_real_socket_timeout_before_success() { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); - let address = listener - .local_addr() - .expect("test listener address must be available"); - let server = thread::spawn(move || listener.accept().map(|_| ())); - let mut stream = TcpStream::connect(address).expect("test client must connect"); - let start = Instant::now(); - let mut now = || start; - - let request_byte_count = - write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) - .expect("the opening request must be written"); - - assert_eq!(request_byte_count, 7); - assert_eq!( - stream - .write_timeout() - .expect("the socket timeout must be inspectable"), - None - ); - assert!(!join_loopback_server(server)); - } - - #[test] - fn panicked_loopback_server_is_reported() { - let server = thread::spawn(|| -> io::Result<()> { - std::panic::resume_unwind(Box::new("intentional test-only server panic")); - }); - - assert!(join_loopback_server(server)); - } - - #[test] - fn bounded_writer_rejects_cleanup_failure_without_success_handoff() { - let mut writer = FakeWriter::new([WriteAction::Count(1)]); - writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); - let start = Instant::now(); - let mut now = || start; - - let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_cleanup_failure = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - .. - } - ) - ) - }; - assert!(is_cleanup_failure(result)); - assert!(!is_cleanup_failure(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } - ))); - } #[test] - fn bounded_writer_rejects_completion_observed_after_total_deadline() { - let mut writer = FakeWriter::new([WriteAction::Count(1)]); - let start = Instant::now(); - let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); - let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); - let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_deadline_after_one = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 1 - } - ) - ) - }; - assert!(is_deadline_after_one(result)); - assert!(!is_deadline_after_one(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } - ))); - } - - #[test] - fn bounded_writer_classifies_deadline_timeout_zero_and_io_failures() { - let start = Instant::now(); - - let mut deadline_writer = FakeWriter::new([]); - let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); - let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); - let deadline = write_request_with_clock( - &mut deadline_writer, - b"x", - Duration::from_secs(1), - &mut deadline_now, - ); - let is_deadline_before_write = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 0 - } - ) - ) - }; - assert!(is_deadline_before_write(deadline)); - assert!(!is_deadline_before_write(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - - let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); - let mut zero_now = || start; - let zero = write_request_with_clock( - &mut zero_writer, - b"x", - Duration::from_secs(1), - &mut zero_now, - ); - let is_zero_write = |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) - ) - }; - assert!(is_zero_write(zero)); - assert!(!is_zero_write(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } - ))); - - for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { - let mut writer = FakeWriter::new([WriteAction::Error(kind)]); - let mut now = || start; - let timed_out = - write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_timed_out = - |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written: 0, - .. - }) - ) - }; - assert!(is_timed_out(timed_out)); - assert!(!is_timed_out(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - source: io::Error::from(kind), - } - ))); - } - - let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - let mut failed_now = || start; - let failed = write_request_with_clock( - &mut failed_writer, - b"x", - Duration::from_secs(1), - &mut failed_now, - ); - let is_failed = |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - .. - }) - ) - }; - assert!(is_failed(failed)); - assert!(!is_failed(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - - let mut configuration_writer = FakeWriter::new([]); - configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); - let mut configuration_now = || start; - let configuration = write_request_with_clock( - &mut configuration_writer, - b"x", - Duration::from_secs(1), - &mut configuration_now, - ); - let is_configuration_failure = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - .. - } - ) - ) - }; - assert!(is_configuration_failure(configuration)); - assert!(!is_configuration_failure(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - } - - #[test] - fn opening_write_errors_have_deterministic_messages_and_sources() { - let invalid = WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { - write_timeout: Duration::ZERO, - maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, - }; - let deadline = - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; - let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }; - let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }; - let zero = WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 }; - let failed = WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }; - let cleanup = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }; - - assert!(!invalid.to_string().is_empty()); - assert!(!deadline.to_string().is_empty()); - assert!(!configure.to_string().is_empty()); - assert!(!timed_out.to_string().is_empty()); - assert!(!zero.to_string().is_empty()); - assert!(!failed.to_string().is_empty()); - assert!(!cleanup.to_string().is_empty()); - assert!(invalid.source().is_none()); - assert!(deadline.source().is_none()); - assert!(configure.source().is_some()); - assert!(timed_out.source().is_some()); - assert!(zero.source().is_none()); - assert!(failed.source().is_some()); - assert!(cleanup.source().is_some()); - } - - #[test] - fn frame_codec_reader_writer_and_errors_are_fully_bounded() { - let masking_key = WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]); - assert_eq!(masking_key.as_bytes(), &[0x37, 0xfa, 0x21, 0x3d]); - for payload in [vec![b'x'; 125], vec![b'x'; 126], vec![b'x'; 65_536]] { - let frame = serialize_text_frame(&payload, masking_key); - assert_eq!(frame[0], 0x81); - assert_ne!(frame[1] & 0x80, 0); - let mask_offset = match payload.len() { - 0..=125 => 2, - 126..=65_535 => 4, - _ => 10, - }; - assert_eq!(&frame[mask_offset..mask_offset + 4], masking_key.as_bytes()); - } - - let start = Instant::now(); - let valid = [0x81, 0x01, b'x']; - let mut valid_reader = FakeReader::new(byte_actions(&valid)); - let valid_frame = read_frame_with_fake(&mut valid_reader, [start]).expect("valid frame"); - assert!(valid_frame.fin()); - assert_eq!(valid_frame.opcode(), 0x1); - assert_eq!(valid_frame.payload(), b"x"); - - let mut ping_reader = FakeReader::new([ReadAction::Byte(0x89), ReadAction::Byte(0)]); - let ping = read_frame_with_fake(&mut ping_reader, [start]).expect("ping frame"); - assert!(ping.fin()); - assert_eq!(ping.opcode(), 0x9); - - let mut continuation_reader = - FakeReader::new([ReadAction::Byte(0x00), ReadAction::Byte(0)]); - let continuation = - read_frame_with_fake(&mut continuation_reader, [start]).expect("continuation frame"); - assert!(!continuation.fin()); - assert_eq!(continuation.opcode(), 0); - - let mut extended_16 = FakeReader::new( - byte_actions(&[0x81, 126, 0, 126]) - .into_iter() - .chain([ReadAction::Count(126)]), - ); - assert_eq!( - read_frame_with_fake(&mut extended_16, [start]) - .expect("extended frame") - .payload() - .len(), - 126 - ); - let mut extended_64 = FakeReader::new( - byte_actions(&[0x81, 127, 0, 0, 0, 0, 0, 1, 0, 0]) - .into_iter() - .chain([ReadAction::Count(65_536)]), - ); - assert_eq!( - read_frame_with_fake(&mut extended_64, [start]) - .expect("large extended frame") - .payload() - .len(), - 65_536 - ); - let mut extended_16_error = FakeReader::new([ - ReadAction::Byte(0x81), - ReadAction::Byte(126), - ReadAction::Error(io::ErrorKind::BrokenPipe), - ]); - assert!(read_frame_with_fake(&mut extended_16_error, [start]).is_err()); - let mut extended_64_error = FakeReader::new([ - ReadAction::Byte(0x81), - ReadAction::Byte(127), - ReadAction::Error(io::ErrorKind::BrokenPipe), - ]); - assert!(read_frame_with_fake(&mut extended_64_error, [start]).is_err()); - - let mut oversized_header = vec![0x81, 127]; - oversized_header - .extend_from_slice(&((MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64) + 1).to_be_bytes()); - let mut malformed_readers = vec![ - vec![0xc1, 0], - vec![0x09, 0], - vec![0x83, 0], - vec![0x81, 0x80], - vec![0x81, 126, 0, 1], - vec![0x81, 127, 0x80, 0, 0, 0, 0, 0, 0, 0], - vec![0x81, 127, 0, 0, 0, 0, 0, 0, 0xff, 0xff], - vec![0x89, 126, 0, 126], - oversized_header, - ]; - for bytes in malformed_readers.drain(..) { - let mut reader = FakeReader::new(byte_actions(&bytes)); - assert!(read_frame_with_fake(&mut reader, [start]).is_err()); - } - let mut count_reader = FakeReader::new([ReadAction::Count(3)]); - assert!(read_frame_with_fake(&mut count_reader, [start]).is_err()); - let mut ended_reader = FakeReader::new([ReadAction::Byte(0x81), ReadAction::End]); - assert!(read_frame_with_fake(&mut ended_reader, [start]).is_err()); - let mut interrupted_reader = FakeReader::new( - std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) - .chain(byte_actions(&valid)), - ); - assert!(read_frame_with_fake(&mut interrupted_reader, [start]).is_ok()); - for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { - let mut retrying_reader = FakeReader::new( - std::iter::once(ReadAction::Error(kind)).chain(byte_actions(&valid)), - ); - assert!(read_frame_with_fake(&mut retrying_reader, [start]).is_ok()); - } - let mut payload_error_reader = FakeReader::new([ - ReadAction::Byte(0x81), - ReadAction::Byte(1), - ReadAction::Error(io::ErrorKind::BrokenPipe), - ]); - assert!(read_frame_with_fake(&mut payload_error_reader, [start]).is_err()); - let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); - assert!(read_frame_with_fake(&mut failed_reader, [start]).is_err()); - let mut mode_reader = FakeReader::new([]); - mode_reader.mode_error = Some(io::ErrorKind::InvalidInput); - assert!(read_frame_with_fake(&mut mode_reader, [start]).is_err()); - let mut timeout_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::WouldBlock)]); - assert!( - read_frame_with_fake( - &mut timeout_reader, - [start, start, start + Duration::from_secs(1)] - ) - .is_err() - ); - let mut deadline_reader = FakeReader::new([]); - assert!( - read_frame_with_fake( - &mut deadline_reader, - [start, start + Duration::from_secs(1)] - ) - .is_err() - ); - let mut cleanup_reader = FakeReader::new(byte_actions(&valid)); - cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); - assert!(read_frame_with_fake(&mut cleanup_reader, [start]).is_err()); - - let mut writer = FakeWriter::new([ - WriteAction::Count(1), - WriteAction::Error(io::ErrorKind::Interrupted), - WriteAction::Count(99), - ]); - let mut now = || start; - assert_eq!( - write_frame_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now) - .expect("frame write"), - 5 - ); - let mut empty_writer = FakeWriter::new([]); - let mut empty_now = || start; - assert_eq!( - write_frame_with_clock( - &mut empty_writer, - b"", - Duration::from_secs(1), - &mut empty_now - ) - .expect("empty frame write"), - 0 - ); - let mut deadline_writer = FakeWriter::new([]); - let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); - let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); - assert!( - write_frame_with_clock( - &mut deadline_writer, - b"x", - Duration::from_secs(1), - &mut deadline_now - ) - .is_err() - ); - let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); - let mut zero_now = || start; - assert!( - write_frame_with_clock( - &mut zero_writer, - b"x", - Duration::from_secs(1), - &mut zero_now - ) - .is_err() - ); - for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { - let mut writer = FakeWriter::new([WriteAction::Error(kind)]); - let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); - let mut now = || times.pop_front().unwrap_or(start); - assert!( - write_frame_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now) - .is_err() - ); + fn close_status_code_validation_covers_wire_bounds_and_forbidden_sentinels() { + for code in [999_u16, 1005, 1006, 1015, 5000] { + assert!(!is_valid_close_status_code(code)); } - let mut retrying_writer = FakeWriter::new([ - WriteAction::Error(io::ErrorKind::WouldBlock), - WriteAction::Count(1), - ]); - let mut retrying_now = || start; - assert_eq!( - write_frame_with_clock( - &mut retrying_writer, - b"x", - Duration::from_secs(1), - &mut retrying_now - ) - .expect("retrying frame write"), - 1 - ); - let mut interrupted_writer = FakeWriter::new([ - WriteAction::Error(io::ErrorKind::Interrupted), - WriteAction::Count(1), - ]); - let mut interrupted_now = || start; - assert_eq!( - write_frame_with_clock( - &mut interrupted_writer, - b"x", - Duration::from_secs(1), - &mut interrupted_now - ) - .expect("interrupted frame write"), - 1 - ); - let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - let mut failed_now = || start; - assert!( - write_frame_with_clock( - &mut failed_writer, - b"x", - Duration::from_secs(1), - &mut failed_now - ) - .is_err() - ); - let mut configuration_writer = FakeWriter::new([]); - configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); - let mut configuration_now = || start; - assert!( - write_frame_with_clock( - &mut configuration_writer, - b"x", - Duration::from_secs(1), - &mut configuration_now - ) - .is_err() - ); - let mut cleanup_writer = FakeWriter::new([WriteAction::Count(1)]); - cleanup_writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); - let mut cleanup_now = || start; - assert!( - write_frame_with_clock( - &mut cleanup_writer, - b"x", - Duration::from_secs(1), - &mut cleanup_now - ) - .is_err() - ); - - for timeout in [ - Duration::ZERO, - MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1), - ] { - assert!(validate_frame_timeout(timeout).is_err()); + for code in [1000_u16, 3000, 4000, 4999] { + assert!(is_valid_close_status_code(code)); } - let errors = [ - WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { - frame_timeout: Duration::ZERO, - maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, - }, - WebDriverBiDiWebSocketFrameError::FrameTooLarge { - payload_bytes: 2, - maximum_bytes: 1, - }, - WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { - source: io::Error::from(io::ErrorKind::InvalidInput), - }, - WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }, - WebDriverBiDiWebSocketFrameError::FrameReadFailed { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }, - WebDriverBiDiWebSocketFrameError::FrameEnded { bytes_read: 1 }, - WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "test" }, - WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }, - WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }, - WebDriverBiDiWebSocketFrameError::FrameWriteFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }, - WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written: 1 }, - WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { - source: io::Error::from(io::ErrorKind::InvalidInput), - }, - ]; - for (error, has_source) in errors.iter().zip([ - false, false, true, true, true, false, false, true, true, true, false, true, - ]) { - assert!(!error.to_string().is_empty()); - assert_eq!(error.source().is_some(), has_source); - } - } - - #[test] - fn established_frame_write_discards_locally_revoked_streams() { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); - let address = listener - .local_addr() - .expect("test listener address must be available"); - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("test server must accept"); - stream - .write_all(&valid_response()) - .expect("test server must write response"); - }); - - let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( - "ws://{address}/session/01234567-89ab-cdef-0123-456789abcdef" - )) - .expect("test endpoint must be valid"); - let correlated = endpoint - .correlate_session_id("01234567-89ab-cdef-0123-456789abcdef") - .expect("test session must correlate"); - let target = correlated - .into_explicit_connect_target() - .expect("test target must be explicit"); - let connection = - crate::WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) - .expect("test connection plan must be valid") - .connect() - .expect("test connection must succeed"); - let sent = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) - .expect("test handshake plan must be valid") - .write_opening_request(Duration::from_secs(1)) - .expect("test opening request must be written"); - let established = sent - .read_opening_response(Duration::from_secs(1)) - .expect("test opening response must be valid"); - let _ = established.stream.shutdown(Shutdown::Both); - assert!( - established - .write_text_frame( - "x", - WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]), - Duration::from_secs(1), - ) - .is_err() - ); - assert!(server.join().is_ok()); } } From 31061c4be8f44585ba1d0730dc37ca7c45678671 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:03:17 -0700 Subject: [PATCH 295/570] revert(network): restore full WebSocket transport source --- .../src/webdriver_bidi_websocket_handshake.rs | 1963 +++++++++++++++-- 1 file changed, 1835 insertions(+), 128 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index d17de8d5f..4e42217f9 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -190,6 +190,16 @@ impl WebDriverBiDiWebSocketHandshakePlan { } /// Write the complete bounded opening request on the exact verified stream within one deadline. + /// + /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an + /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the + /// destination, or retries after any other I/O failure. A partial write that cannot finish before + /// the same monotonic deadline is an error and yields no successful handoff. Before success, the + /// operation-local socket write timeout is cleared so the next separately reviewed protocol stage + /// cannot inherit stale timeout authority. Success preserves the live stream, exact transport + /// evidence, and client key for a separately reviewed server handshake validator. It does not + /// read or validate the server response and therefore does not establish WebSocket protocol state + /// or browser/Agent authority. pub fn write_opening_request( self, write_timeout: Duration, @@ -224,6 +234,14 @@ impl WebDriverBiDiWebSocketHandshakePlan { } } +/// A live verified stream after the complete client opening request has been written. +/// +/// This state proves only that the exact bounded RFC 6455 client request reached the operating +/// system's verified TCP stream before the configured deadline and that this operation's socket write +/// timeout was cleared before handoff. It deliberately does not claim that the peer returned `101 +/// Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket is established, or +/// that the peer is the expected Chromium/ChromeDriver process. Those remain separate fail-closed +/// boundaries. pub struct WebDriverBiDiWebSocketOpeningRequestSent { pub(crate) stream: TcpStream, transport_evidence: WebDriverBiDiTcpConnectionEvidence, @@ -238,7 +256,10 @@ impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") .field("stream_local_addr", &self.stream.local_addr().ok()) .field("transport_evidence", &self.transport_evidence) - .field("client_key", &"") + .field( + "client_key", + &"", + ) .field("request_byte_count", &self.request_byte_count) .field("write_timeout", &self.write_timeout) .finish() @@ -246,26 +267,36 @@ impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { } impl WebDriverBiDiWebSocketOpeningRequestSent { + /// Borrow the exact verified transport evidence retained with this live stream. #[must_use] pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { &self.transport_evidence } + /// Borrow the exact client key required to validate the later server accept value. #[must_use] pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { &self.client_key } + /// Return the exact number of opening-request bytes written before success was emitted. #[must_use] pub const fn request_byte_count(&self) -> usize { self.request_byte_count } + /// Return the total write deadline configured for this opening request. #[must_use] pub const fn write_timeout(&self) -> Duration { self.write_timeout } + /// Read and validate the bounded RFC 6455 server opening response on this exact stream. + /// + /// Success proves only an HTTP/1.1 `101 Switching Protocols` response with the required + /// `Upgrade`, `Connection`, and client-key-correlated `Sec-WebSocket-Accept` headers. The + /// response body, WebSocket frames, browser process identity, TLS, and browser/Agent authority + /// remain separate boundaries. pub fn read_opening_response( self, response_timeout: Duration, @@ -304,6 +335,11 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { } } +/// A live verified stream after both RFC 6455 opening messages were validated. +/// +/// This state does not implement WebSocket framing or grant browser, page, policy, or Agent +/// authority. It retains the exact transport evidence and client key so later protocol stages can +/// remain correlated with the verified peer and opening handshake. pub struct WebDriverBiDiWebSocketEstablished { pub(crate) stream: TcpStream, transport_evidence: WebDriverBiDiTcpConnectionEvidence, @@ -321,7 +357,10 @@ impl fmt::Debug for WebDriverBiDiWebSocketEstablished { .debug_struct("WebDriverBiDiWebSocketEstablished") .field("stream_local_addr", &self.stream.local_addr().ok()) .field("transport_evidence", &self.transport_evidence) - .field("client_key", &"") + .field( + "client_key", + &"", + ) .field("response_status", &self.response_status) .field("response_byte_count", &self.response_byte_count) .field("response_timeout", &self.response_timeout) @@ -332,41 +371,54 @@ impl fmt::Debug for WebDriverBiDiWebSocketEstablished { } impl WebDriverBiDiWebSocketEstablished { + /// Borrow the exact verified transport evidence retained with this live stream. #[must_use] pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { &self.transport_evidence } + /// Borrow the exact client key correlated with the validated server accept value. #[must_use] pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { &self.client_key } + /// Return the validated HTTP status code, currently always `101` on success. #[must_use] pub const fn response_status(&self) -> u16 { self.response_status } + /// Return the number of HTTP opening-response bytes consumed through its header terminator. #[must_use] pub const fn response_byte_count(&self) -> usize { self.response_byte_count } + /// Return the total response deadline configured for this opening response. #[must_use] pub const fn response_timeout(&self) -> Duration { self.response_timeout } + /// Return the number of request bytes written before the response was read. #[must_use] pub const fn request_byte_count(&self) -> usize { self.request_byte_count } + /// Return the total write deadline configured for the preceding opening request. #[must_use] pub const fn write_timeout(&self) -> Duration { self.write_timeout } + /// Write one unfragmented, masked UTF-8 text frame on this verified stream. + /// + /// The operation consumes the established state and returns it only after the complete frame + /// is written and the temporary socket timeout is cleared. The caller must provide a fresh, + /// unpredictable masking key for this frame; it is never exposed in evidence or debug output. + /// This method does not translate JSON, create a BiDi session, or grant browser/Agent authority. pub fn write_text_frame( self, text: &str, @@ -406,6 +458,14 @@ impl WebDriverBiDiWebSocketEstablished { }) } + /// Read one bounded RFC 6455 frame from this verified stream. + /// + /// Server-to-client frames must be unmasked. Data and continuation frames are returned one at + /// a time so a later message layer can enforce fragmentation and JSON semantics; control frames + /// are returned to that layer for protocol handling. Reserved bits/opcodes, oversized payloads, + /// noncanonical lengths, and incomplete reads fail closed. Close frames additionally enforce the + /// RFC 6455 payload shape and UTF-8 reason contract before the frame is returned. No frame grants + /// browser/Agent authority. pub fn read_frame( self, frame_timeout: Duration, @@ -439,6 +499,7 @@ impl WebDriverBiDiWebSocketEstablished { } } +/// One validated WebSocket frame received from the established peer. #[derive(Debug, Eq, PartialEq)] pub struct WebDriverBiDiWebSocketFrame { fin: bool, @@ -447,16 +508,19 @@ pub struct WebDriverBiDiWebSocketFrame { } impl WebDriverBiDiWebSocketFrame { + /// Return whether this is the final frame in its message. #[must_use] pub const fn fin(&self) -> bool { self.fin } + /// Return the RFC 6455 opcode without interpreting application semantics. #[must_use] pub const fn opcode(&self) -> u8 { self.opcode } + /// Borrow the bounded, unmasked application payload. #[must_use] pub fn payload(&self) -> &[u8] { &self.payload @@ -473,53 +537,81 @@ fn validate_frame_timeout(frame_timeout: Duration) -> Result<(), WebDriverBiDiWe Ok(()) } -fn is_valid_close_status_code(status_code: u16) -> bool { - (1000..=4999).contains(&status_code) && !matches!(status_code, 1005 | 1006 | 1015) -} - +/// Fail-closed errors while reading or writing one bounded WebSocket frame. #[derive(Debug)] pub enum WebDriverBiDiWebSocketFrameError { + /// The requested frame I/O deadline was zero or above the reviewed resource ceiling. InvalidFrameTimeout { + /// Rejected caller-supplied deadline. frame_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. maximum_timeout: Duration, }, + /// The frame payload exceeded the reviewed memory ceiling. FrameTooLarge { + /// Rejected payload length in bytes. payload_bytes: usize, + /// Maximum payload length admitted by this boundary. maximum_bytes: usize, }, + /// Applying the operation-local nonblocking read mode failed. FrameReadModeConfigurationFailed { + /// Underlying operating-system error. source: io::Error, }, + /// A bounded socket read timed out before the frame was complete. FrameReadTimedOut { + /// Number of frame bytes consumed before timeout. bytes_read: usize, + /// Underlying operating-system error. source: io::Error, }, + /// A non-recoverable socket read failed before the frame was complete. FrameReadFailed { + /// Number of frame bytes consumed before failure. bytes_read: usize, + /// Underlying operating-system error. source: io::Error, }, + /// The peer ended the stream before the frame was complete. FrameEnded { + /// Number of frame bytes consumed before EOF. bytes_read: usize, }, + /// The frame header or RFC 6455 control-frame payload violated the protocol contract. MalformedFrame { + /// Stable, non-secret reason for rejection. reason: &'static str, }, + /// Applying the operation-local write timeout failed. FrameWriteModeConfigurationFailed { + /// Number of frame bytes already written before configuration failed. bytes_written: usize, + /// Underlying operating-system error. source: io::Error, }, + /// A bounded socket write timed out before the frame was complete. FrameWriteTimedOut { + /// Number of frame bytes written before timeout. bytes_written: usize, + /// Underlying operating-system error. source: io::Error, }, + /// A non-recoverable socket write failed before the frame was complete. FrameWriteFailed { + /// Number of frame bytes written before failure. bytes_written: usize, + /// Underlying operating-system error. source: io::Error, }, + /// The stream reported zero progress before the frame was complete. FrameWriteZero { + /// Number of frame bytes written before zero progress. bytes_written: usize, }, + /// Clearing the temporary write timeout failed before handoff. FrameWriteCleanupFailed { + /// Underlying operating-system error. source: io::Error, }, } @@ -527,18 +619,41 @@ pub enum WebDriverBiDiWebSocketFrameError { impl fmt::Display for WebDriverBiDiWebSocketFrameError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidFrameTimeout { .. } => formatter.write_str("WebDriver BiDi WebSocket frame timeout is outside the reviewed bound"), - Self::FrameTooLarge { .. } => formatter.write_str("WebDriver BiDi WebSocket frame payload exceeded its bound"), - Self::FrameReadModeConfigurationFailed { .. } => formatter.write_str("failed to configure bounded WebSocket frame reads"), - Self::FrameReadTimedOut { .. } => formatter.write_str("WebDriver BiDi WebSocket frame read timed out"), - Self::FrameReadFailed { .. } => formatter.write_str("WebDriver BiDi WebSocket frame read failed"), - Self::FrameEnded { .. } => formatter.write_str("WebDriver BiDi WebSocket peer ended the frame stream"), - Self::MalformedFrame { .. } => formatter.write_str("WebDriver BiDi WebSocket frame was malformed"), - Self::FrameWriteModeConfigurationFailed { .. } => formatter.write_str("failed to configure bounded WebSocket frame writes"), - Self::FrameWriteTimedOut { .. } => formatter.write_str("WebDriver BiDi WebSocket frame write timed out"), - Self::FrameWriteFailed { .. } => formatter.write_str("WebDriver BiDi WebSocket frame write failed"), - Self::FrameWriteZero { .. } => formatter.write_str("WebDriver BiDi WebSocket frame write made no progress"), - Self::FrameWriteCleanupFailed { .. } => formatter.write_str("failed to clear the WebDriver BiDi WebSocket frame timeout"), + Self::InvalidFrameTimeout { .. } => formatter + .write_str("WebDriver BiDi WebSocket frame timeout is outside the reviewed bound"), + Self::FrameTooLarge { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame payload exceeded its bound") + } + Self::FrameReadModeConfigurationFailed { .. } => { + formatter.write_str("failed to configure bounded WebSocket frame reads") + } + Self::FrameReadTimedOut { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame read timed out") + } + Self::FrameReadFailed { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame read failed") + } + Self::FrameEnded { .. } => { + formatter.write_str("WebDriver BiDi WebSocket peer ended the frame stream") + } + Self::MalformedFrame { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame was malformed") + } + Self::FrameWriteModeConfigurationFailed { .. } => { + formatter.write_str("failed to configure bounded WebSocket frame writes") + } + Self::FrameWriteTimedOut { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write timed out") + } + Self::FrameWriteFailed { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write failed") + } + Self::FrameWriteZero { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write made no progress") + } + Self::FrameWriteCleanupFailed { .. } => { + formatter.write_str("failed to clear the WebDriver BiDi WebSocket frame timeout") + } } } } @@ -562,33 +677,101 @@ impl Error for WebDriverBiDiWebSocketFrameError { } } +/// Fail-closed errors while reading one bounded WebDriver BiDi WebSocket opening response. #[derive(Debug)] pub enum WebDriverBiDiWebSocketHandshakeResponseError { - InvalidResponseTimeout { response_timeout: Duration, maximum_timeout: Duration }, - ResponseDeadlineExceeded { bytes_read: usize }, - ResponseTooLarge { bytes_read: usize, maximum_bytes: usize }, - ResponseReadModeConfigurationFailed { bytes_read: usize, source: io::Error }, - ResponseReadTimedOut { bytes_read: usize, source: io::Error }, - ResponseReadFailed { bytes_read: usize, source: io::Error }, - ResponseEndedBeforeHeaders { bytes_read: usize }, - MalformedResponse { reason: &'static str }, + /// The requested total response deadline was zero or above the reviewed resource ceiling. + InvalidResponseTimeout { + /// Rejected caller-supplied deadline. + response_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The monotonic total response deadline elapsed before validation completed. + ResponseDeadlineExceeded { + /// Number of response bytes consumed before the deadline elapsed. + bytes_read: usize, + }, + /// The response exceeded the reviewed header-size ceiling before its terminator was found. + ResponseTooLarge { + /// Number of response bytes consumed before rejection. + bytes_read: usize, + /// Maximum response bytes admitted by this boundary. + maximum_bytes: usize, + }, + /// Applying the operation-local nonblocking read mode failed. + ResponseReadModeConfigurationFailed { + /// Number of response bytes consumed before configuration failed. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket read timed out before the opening response was complete. + ResponseReadTimedOut { + /// Number of response bytes consumed before the timed-out operation. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A non-recoverable socket read failed before the opening response was complete. + ResponseReadFailed { + /// Number of response bytes consumed before the failure. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// The peer closed the stream before sending a complete HTTP header block. + ResponseEndedBeforeHeaders { + /// Number of response bytes consumed before the peer closed the stream. + bytes_read: usize, + }, + /// The HTTP response was not a valid, required WebSocket opening response. + MalformedResponse { + /// Stable, non-secret reason for the rejected response shape. + reason: &'static str, + }, + /// The response's `Sec-WebSocket-Accept` did not correlate with the sent client key. AcceptMismatch, - ReadModeCleanupFailed { source: io::Error }, + /// Restoring blocking mode failed after validation. + ReadModeCleanupFailed { + /// Underlying operating-system error. + source: io::Error, + }, } impl fmt::Display for WebDriverBiDiWebSocketHandshakeResponseError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InvalidResponseTimeout { .. } => formatter.write_str("WebDriver BiDi WebSocket opening response timeout is outside the reviewed bound"), - Self::ResponseDeadlineExceeded { .. } => formatter.write_str("WebDriver BiDi WebSocket opening response exceeded its monotonic deadline"), - Self::ResponseTooLarge { .. } => formatter.write_str("WebDriver BiDi WebSocket opening response exceeded its bounded header size"), - Self::ResponseReadModeConfigurationFailed { .. } => formatter.write_str("failed to configure bounded nonblocking WebDriver BiDi WebSocket response reads"), - Self::ResponseReadTimedOut { .. } => formatter.write_str("WebDriver BiDi WebSocket opening response timed out before completion"), - Self::ResponseReadFailed { .. } => formatter.write_str("WebDriver BiDi WebSocket opening response read failed before completion"), - Self::ResponseEndedBeforeHeaders { .. } => formatter.write_str("WebDriver BiDi WebSocket peer ended the stream before completing response headers"), - Self::MalformedResponse { .. } => formatter.write_str("WebDriver BiDi WebSocket opening response was malformed or missing a required header"), - Self::AcceptMismatch => formatter.write_str("WebDriver BiDi WebSocket opening response accept value did not match the client key"), - Self::ReadModeCleanupFailed { .. } => formatter.write_str("failed to restore blocking WebDriver BiDi WebSocket response reads before handoff"), + Self::InvalidResponseTimeout { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response timeout is outside the reviewed bound", + ), + Self::ResponseDeadlineExceeded { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response exceeded its monotonic deadline", + ), + Self::ResponseTooLarge { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response exceeded its bounded header size", + ), + Self::ResponseReadModeConfigurationFailed { .. } => formatter.write_str( + "failed to configure bounded nonblocking WebDriver BiDi WebSocket response reads", + ), + Self::ResponseReadTimedOut { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response timed out before completion", + ), + Self::ResponseReadFailed { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response read failed before completion", + ), + Self::ResponseEndedBeforeHeaders { .. } => formatter.write_str( + "WebDriver BiDi WebSocket peer ended the stream before completing response headers", + ), + Self::MalformedResponse { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response was malformed or missing a required header", + ), + Self::AcceptMismatch => formatter.write_str( + "WebDriver BiDi WebSocket opening response accept value did not match the client key", + ), + Self::ReadModeCleanupFailed { .. } => formatter.write_str( + "failed to restore blocking WebDriver BiDi WebSocket response reads before handoff", + ), } } } @@ -600,12 +783,20 @@ impl Error for WebDriverBiDiWebSocketHandshakeResponseError { | Self::ResponseReadTimedOut { source, .. } | Self::ResponseReadFailed { source, .. } | Self::ReadModeCleanupFailed { source } => Some(source), - _ => None, + Self::InvalidResponseTimeout { .. } + | Self::ResponseDeadlineExceeded { .. } + | Self::ResponseTooLarge { .. } + | Self::ResponseEndedBeforeHeaders { .. } + | Self::MalformedResponse { .. } + | Self::AcceptMismatch => None, } } } -struct ParsedOpeningResponse { status_code: u16, byte_count: usize } +struct ParsedOpeningResponse { + status_code: u16, + byte_count: usize, +} fn expected_accept_value(client_key: &WebDriverBiDiWebSocketClientKey) -> String { let mut digest = Sha1::new(); @@ -615,153 +806,1669 @@ fn expected_accept_value(client_key: &WebDriverBiDiWebSocketClientKey) -> String } fn is_http_token_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || matches!(byte, b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~') + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) } fn has_header_token(value: &str, expected: &str) -> bool { - value.split(',').map(str::trim).any(|token| token.eq_ignore_ascii_case(expected)) + value + .split(',') + .map(str::trim) + .any(|token| token.eq_ignore_ascii_case(expected)) } #[allow(clippy::collapsible_if)] -fn parse_opening_response(response: &[u8], client_key: &WebDriverBiDiWebSocketClientKey) -> Result { +fn parse_opening_response( + response: &[u8], + client_key: &WebDriverBiDiWebSocketClientKey, +) -> Result { if !response.ends_with(b"\r\n\r\n") { - return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "response is missing its CRLF header terminator" }); - } - let response_text = std::str::from_utf8(response).map_err(|_| WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "response headers are not valid UTF-8" })?; - let header_text = &response_text[..response_text.len()-4]; - let (status_line, header_lines) = header_text.split_once("\r\n").map_or((header_text, ""), |(line, rest)| (line, rest)); - if status_line.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "status line contains a control byte" }); } - let status_code = status_line.strip_prefix("HTTP/1.1 ").and_then(|rest| rest.split_whitespace().next()).and_then(|value| value.parse::().ok()); - if status_code != Some(101) { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "status line is not HTTP/1.1 101" }); } + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response is missing its CRLF header terminator", + }, + ); + } + let response_text = std::str::from_utf8(response).map_err(|_| { + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response headers are not valid UTF-8", + } + })?; + let header_text = &response_text[..response_text.len() - 4]; + let (status_line, header_lines) = header_text + .split_once("\r\n") + .map_or((header_text, ""), |(line, rest)| (line, rest)); + if status_line.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "status line contains a control byte", + }, + ); + } + let status_code = status_line + .strip_prefix("HTTP/1.1 ") + .and_then(|rest| rest.split_whitespace().next()) + .and_then(|value| value.parse::().ok()); + if status_code != Some(101) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "status line is not HTTP/1.1 101", + }, + ); + } + let mut upgrade_has_websocket = false; let mut connection_has_upgrade = false; let mut accept = None; for line in header_lines.split("\r\n") { - if line.is_empty() || line.as_bytes().first().is_some_and(|byte| matches!(byte, b' ' | b'\t')) { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "header line is empty or folded" }); } - let (name, value) = line.split_once(':').ok_or(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "header line has no colon" })?; - if name.is_empty() || !name.bytes().all(is_http_token_byte) { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "header name is not an HTTP token" }); } + if line.is_empty() + || line + .as_bytes() + .first() + .is_some_and(|byte| matches!(byte, b' ' | b'\t')) + { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header line is empty or folded", + }, + ); + } + let (name, value) = line.split_once(':').ok_or( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header line has no colon", + }, + )?; + if name.is_empty() || !name.bytes().all(is_http_token_byte) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header name is not an HTTP token", + }, + ); + } let value = value.trim_matches([' ', '\t']); - if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "header value contains a control byte" }); } - if name.eq_ignore_ascii_case("upgrade") { upgrade_has_websocket |= has_header_token(value, "websocket"); } - else if name.eq_ignore_ascii_case("connection") { connection_has_upgrade |= has_header_token(value, "upgrade"); } - else if name.eq_ignore_ascii_case("sec-websocket-accept") { if accept.is_some() { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "response repeats the Sec-WebSocket-Accept header" }); } accept = Some(value); } + if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header value contains a control byte", + }, + ); + } + if name.eq_ignore_ascii_case("upgrade") { + upgrade_has_websocket |= has_header_token(value, "websocket"); + } else if name.eq_ignore_ascii_case("connection") { + connection_has_upgrade |= has_header_token(value, "upgrade"); + } else if name.eq_ignore_ascii_case("sec-websocket-accept") { + if accept.is_some() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response repeats the Sec-WebSocket-Accept header", + }, + ); + } + accept = Some(value); + } + } + + if !upgrade_has_websocket { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "Upgrade header does not contain websocket", + }, + ); + } + if !connection_has_upgrade { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "Connection header does not contain Upgrade", + }, + ); } - if !upgrade_has_websocket { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "Upgrade header does not contain websocket" }); } - if !connection_has_upgrade { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "Connection header does not contain Upgrade" }); } - let Some(accept) = accept else { return Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "response has no Sec-WebSocket-Accept header" }); }; - if accept != expected_accept_value(client_key) { return Err(WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch); } - Ok(ParsedOpeningResponse { status_code: 101, byte_count: response.len() }) + let Some(accept) = accept else { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response has no Sec-WebSocket-Accept header", + }, + ); + }; + if accept != expected_accept_value(client_key) { + return Err(WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch); + } + + Ok(ParsedOpeningResponse { + status_code: 101, + byte_count: response.len(), + }) } trait OpeningResponseReader { fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()>; fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result; } + impl OpeningResponseReader for TcpStream { - fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { TcpStream::set_nonblocking(self, nonblocking) } - fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { self.read(bytes) } + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { + TcpStream::set_nonblocking(self, nonblocking) + } + + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { + self.read(bytes) + } } fn serialize_text_frame(payload: &[u8], masking_key: WebDriverBiDiWebSocketMaskKey) -> Vec { - let mut frame = Vec::with_capacity(payload.len()+14); + let mut frame = Vec::with_capacity(payload.len() + 14); frame.push(0x81); match payload.len() { 0..=125 => frame.push(0x80 | payload.len() as u8), - 126..=65_535 => { frame.push(0x80 | 126); frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); } - length => { frame.push(0x80 | 127); frame.extend_from_slice(&(length as u64).to_be_bytes()); } + 126..=65_535 => { + frame.push(0x80 | 126); + frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + } + length => { + frame.push(0x80 | 127); + frame.extend_from_slice(&(length as u64).to_be_bytes()); + } } frame.extend_from_slice(masking_key.as_bytes()); - frame.extend(payload.iter().enumerate().map(|(index, byte)| byte ^ masking_key.as_bytes()[index % masking_key.as_bytes().len()])); + frame.extend( + payload.iter().enumerate().map(|(index, byte)| { + byte ^ masking_key.as_bytes()[index % masking_key.as_bytes().len()] + }), + ); frame } -trait FrameWriter { fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result; } -impl FrameWriter for TcpStream { fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { TcpStream::set_write_timeout(self, timeout) } fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { self.write(bytes) } } +trait FrameWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result; +} -fn write_frame_with_clock(writer: &mut dyn FrameWriter, frame: &[u8], frame_timeout: Duration, now: &mut dyn FnMut() -> Instant) -> Result { - let deadline = now()+frame_timeout; +impl FrameWriter for TcpStream { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + TcpStream::set_write_timeout(self, timeout) + } + + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_frame_with_clock( + writer: &mut dyn FrameWriter, + frame: &[u8], + frame_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + frame_timeout; let mut bytes_written = 0; while bytes_written < frame.len() { let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { bytes_written, source: io::Error::new(io::ErrorKind::TimedOut, "frame write deadline elapsed") }); } - writer.set_write_timeout(Some(remaining)).map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { bytes_written, source })?; + if remaining.is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written, + source: io::Error::new(io::ErrorKind::TimedOut, "frame write deadline elapsed"), + }); + } + writer + .set_write_timeout(Some(remaining)) + .map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written, + source, + } + })?; match writer.write_frame_bytes(&frame[bytes_written..]) { - Ok(0) => return Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written }), + Ok(0) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written }); + } Ok(written) => bytes_written += written, - Err(source) if source.kind()==io::ErrorKind::Interrupted => continue, - Err(source) if matches!(source.kind(), io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock) => { if deadline.saturating_duration_since(now()).is_zero() { return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { bytes_written, source }); } thread::sleep(Duration::from_millis(1)); continue; }, - Err(source) => return Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { bytes_written, source }), + Err(source) => { + if source.kind() == io::ErrorKind::Interrupted { + continue; + } + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) { + if deadline.saturating_duration_since(now()).is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written, + source, + }); + } + thread::sleep(Duration::from_millis(1)); + continue; + } + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written, + source, + }); + } } } - writer.set_write_timeout(None).map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { source })?; + writer + .set_write_timeout(None) + .map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { source })?; Ok(bytes_written) } -fn read_frame_with_clock(reader: &mut dyn OpeningResponseReader, frame_timeout: Duration, now: &mut dyn FnMut() -> Instant) -> Result { - let deadline = now()+frame_timeout; - reader.set_nonblocking(true).map_err(|source| WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { source })?; - let mut bytes_read=0; - let mut header=[0_u8;2]; - read_frame_bytes_with_clock(reader,&mut header,&mut bytes_read,deadline,now)?; - let first=header[0]; let second=header[1]; - if first & 0x70 != 0 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "reserved frame bits are not negotiated" }); } - let fin=first & 0x80 !=0; let opcode=first & 0x0f; - match opcode { 0x0..=0x2 => {}, 0x8..=0xa => { if !fin { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "control frames must not be fragmented" }); } }, _ => return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "frame opcode is reserved or unsupported" }) } - if second & 0x80 != 0 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "server-to-client frames must not be masked" }); } - let length_code=second & 0x7f; - let payload_length=match length_code { +fn read_frame_with_clock( + reader: &mut dyn OpeningResponseReader, + frame_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + frame_timeout; + reader.set_nonblocking(true).map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { source } + })?; + let mut bytes_read = 0; + let mut header = [0_u8; 2]; + read_frame_bytes_with_clock(reader, &mut header, &mut bytes_read, deadline, now)?; + let first = header[0]; + let second = header[1]; + if first & 0x70 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "reserved frame bits are not negotiated", + }); + } + let fin = first & 0x80 != 0; + let opcode = first & 0x0f; + match opcode { + 0x0..=0x2 => {} + 0x8..=0xa => { + if !fin { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "control frames must not be fragmented", + }); + } + } + _ => { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame opcode is reserved or unsupported", + }); + } + } + if second & 0x80 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "server-to-client frames must not be masked", + }); + } + let length_code = second & 0x7f; + let payload_length = match length_code { 0..=125 => u64::from(length_code), - 126 => { let mut extended=[0_u8;2]; read_frame_bytes_with_clock(reader,&mut extended,&mut bytes_read,deadline,now)?; let length=u64::from(u16::from_be_bytes(extended)); if length<126 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "frame length encoding is not minimal" }); } length }, - _ => { let mut extended=[0_u8;8]; read_frame_bytes_with_clock(reader,&mut extended,&mut bytes_read,deadline,now)?; if extended[0]&0x80!=0 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "frame length uses the reserved high bit" }); } let length=u64::from_be_bytes(extended); if length<65_536 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "frame length encoding is not minimal" }); } length } + 126 => { + let mut extended = [0_u8; 2]; + read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; + let length = u64::from(u16::from_be_bytes(extended)); + if length < 126 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length encoding is not minimal", + }); + } + length + } + _ => { + let mut extended = [0_u8; 8]; + read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; + if extended[0] & 0x80 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length uses the reserved high bit", + }); + } + let length = u64::from_be_bytes(extended); + if length < 65_536 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length encoding is not minimal", + }); + } + length + } }; - if payload_length > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64 { return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { payload_bytes: payload_length.min(usize::MAX as u64) as usize, maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES }); } - if opcode>=0x8 && payload_length>125 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "control frame payload exceeds 125 bytes" }); } - let payload_length=payload_length as usize; - let mut payload=vec![0_u8;payload_length]; - read_frame_bytes_with_clock(reader,&mut payload,&mut bytes_read,deadline,now)?; - if opcode==0x8 { - if payload.len()==1 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "Close frame payload must be empty or begin with a two-byte status code" }); } - if payload.len()>1 { - let status_code=u16::from_be_bytes([payload[0],payload[1]]); - if !is_valid_close_status_code(status_code) { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "Close frame status code is not valid on the wire" }); } - if std::str::from_utf8(&payload[2..]).is_err() { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "Close frame reason is not valid UTF-8" }); } - } - } - reader.set_nonblocking(false).map_err(|source| WebDriverBiDiWebSocketFrameError::FrameReadFailed { bytes_read, source })?; - Ok(WebDriverBiDiWebSocketFrame { fin, opcode, payload }) -} - -fn read_frame_bytes_with_clock(reader:&mut dyn OpeningResponseReader,destination:&mut [u8],bytes_read:&mut usize,deadline:Instant,now:&mut dyn FnMut()->Instant)->Result<(),WebDriverBiDiWebSocketFrameError>{ - let mut offset=0; - while offsetreturn Err(WebDriverBiDiWebSocketFrameError::FrameEnded{bytes_read:*bytes_read}),Ok(read) if read>destination.len()-offset=>return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed{bytes_read:*bytes_read,source:io::Error::new(io::ErrorKind::InvalidData,"frame reader returned more bytes than requested")}),Ok(read)=>{offset+=read;*bytes_read+=read;},Err(source) if source.kind()==io::ErrorKind::Interrupted=>{},Err(source) if matches!(source.kind(),io::ErrorKind::TimedOut|io::ErrorKind::WouldBlock)=>{if deadline.saturating_duration_since(now()).is_zero(){return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut{bytes_read:*bytes_read,source});} thread::sleep(Duration::from_millis(1));},Err(source)=>return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed{bytes_read:*bytes_read,source})}} + if payload_length > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64 { + return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: payload_length.min(usize::MAX as u64) as usize, + maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES, + }); + } + if opcode >= 0x8 && payload_length > 125 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "control frame payload exceeds 125 bytes", + }); + } + let payload_length = payload_length as usize; + let mut payload = vec![0_u8; payload_length]; + read_frame_bytes_with_clock(reader, &mut payload, &mut bytes_read, deadline, now)?; + if opcode == 0x8 { + if payload.len() == 1 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "Close frame payload must be empty or begin with a two-byte status code", + }); + } + if payload.len() > 1 && std::str::from_utf8(&payload[2..]).is_err() { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "Close frame reason is not valid UTF-8", + }); + } + } + reader.set_nonblocking(false).map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameReadFailed { bytes_read, source } + })?; + Ok(WebDriverBiDiWebSocketFrame { + fin, + opcode, + payload, + }) +} + +fn read_frame_bytes_with_clock( + reader: &mut dyn OpeningResponseReader, + destination: &mut [u8], + bytes_read: &mut usize, + deadline: Instant, + now: &mut dyn FnMut() -> Instant, +) -> Result<(), WebDriverBiDiWebSocketFrameError> { + let mut offset = 0; + while offset < destination.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: *bytes_read, + source: io::Error::new(io::ErrorKind::TimedOut, "frame read deadline elapsed"), + }); + } + match reader.read_response_bytes(&mut destination[offset..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameEnded { + bytes_read: *bytes_read, + }); + } + Ok(read) if read > destination.len() - offset => { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: *bytes_read, + source: io::Error::new( + io::ErrorKind::InvalidData, + "frame reader returned more bytes than requested", + ), + }); + } + Ok(read) => { + offset += read; + *bytes_read += read; + } + Err(source) if source.kind() == io::ErrorKind::Interrupted => {} + Err(source) + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + if deadline.saturating_duration_since(now()).is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: *bytes_read, + source, + }); + } + thread::sleep(Duration::from_millis(1)); + } + Err(source) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: *bytes_read, + source, + }); + } + } + } Ok(()) } -fn read_opening_response_with_clock(reader:&mut dyn OpeningResponseReader,client_key:&WebDriverBiDiWebSocketClientKey,response_timeout:Duration,now:&mut dyn FnMut()->Instant)->Result<(u16,usize),WebDriverBiDiWebSocketHandshakeResponseError>{ - let deadline=now()+response_timeout; let mut response=Vec::new(); reader.set_nonblocking(true).map_err(|source|WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed{bytes_read:0,source})?; - loop { let remaining=deadline.saturating_duration_since(now()); if remaining.is_zero(){return Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded{bytes_read:response.len()});} if response.len()>=MAX_WEBSOCKET_OPENING_RESPONSE_BYTES{return Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge{bytes_read:response.len(),maximum_bytes:MAX_WEBSOCKET_OPENING_RESPONSE_BYTES});} let mut byte=[0_u8;1]; match reader.read_response_bytes(&mut byte){Ok(0)=>return Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders{bytes_read:response.len()}),Ok(1)=>{response.push(byte[0]);if response.ends_with(b"\r\n\r\n"){if deadline.saturating_duration_since(now()).is_zero(){return Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded{bytes_read:response.len()});}let parsed=parse_opening_response(&response,client_key)?;reader.set_nonblocking(false).map_err(|source|WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed{source})?;return Ok((parsed.status_code,parsed.byte_count));}},Ok(_)=>return Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed{bytes_read:response.len(),source:io::Error::new(io::ErrorKind::InvalidData,"response reader returned more bytes than requested")}),Err(source) if source.kind()==io::ErrorKind::Interrupted=>{},Err(source) if matches!(source.kind(),io::ErrorKind::TimedOut|io::ErrorKind::WouldBlock)=>{if deadline.saturating_duration_since(now()).is_zero(){return Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut{bytes_read:response.len(),source});}thread::sleep(Duration::from_millis(1));},Err(source)=>return Err(WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed{bytes_read:response.len(),source})}} +fn read_opening_response_with_clock( + reader: &mut dyn OpeningResponseReader, + client_key: &WebDriverBiDiWebSocketClientKey, + response_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { + let deadline = now() + response_timeout; + let mut response = Vec::new(); + + reader.set_nonblocking(true).map_err(|source| { + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 0, + source, + } + })?; + + loop { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: response.len(), + }, + ); + } + if response.len() >= MAX_WEBSOCKET_OPENING_RESPONSE_BYTES { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { + bytes_read: response.len(), + maximum_bytes: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + }, + ); + } + let mut byte = [0_u8; 1]; + match reader.read_response_bytes(&mut byte) { + Ok(0) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { + bytes_read: response.len(), + }, + ); + } + Ok(1) => { + response.push(byte[0]); + if response.ends_with(b"\r\n\r\n") { + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: response.len(), + }, + ); + } + let parsed = parse_opening_response(&response, client_key)?; + reader.set_nonblocking(false).map_err(|source| { + WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { + source, + } + })?; + return Ok((parsed.status_code, parsed.byte_count)); + } + } + Ok(_) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: response.len(), + source: io::Error::new( + io::ErrorKind::InvalidData, + "response reader returned more bytes than requested", + ), + }, + ); + } + Err(source) if source.kind() == io::ErrorKind::Interrupted => {} + Err(source) + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { + bytes_read: response.len(), + source, + }, + ); + } + thread::sleep(Duration::from_millis(1)); + } + Err(source) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: response.len(), + source, + }, + ); + } + } + } } +/// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. #[derive(Debug)] -pub enum WebDriverBiDiWebSocketOpeningWriteError { InvalidWriteTimeout{write_timeout:Duration,maximum_timeout:Duration},WriteDeadlineExceeded{bytes_written:usize},WriteTimeoutConfigurationFailed{bytes_written:usize,source:io::Error},WriteTimedOut{bytes_written:usize,source:io::Error},WriteZero{bytes_written:usize},WriteFailed{bytes_written:usize,source:io::Error},WriteTimeoutCleanupFailed{bytes_written:usize,source:io::Error} } -impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError{fn fmt(&self,formatter:&mut fmt::Formatter<'_>)->fmt::Result{match self{Self::InvalidWriteTimeout{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound"),Self::WriteDeadlineExceeded{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write exceeded its monotonic deadline"),Self::WriteTimeoutConfigurationFailed{..}=>formatter.write_str("failed to configure the bounded WebDriver BiDi WebSocket opening write timeout"),Self::WriteTimedOut{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write timed out before the request was complete"),Self::WriteZero{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write returned zero before the request was complete"),Self::WriteFailed{..}=>formatter.write_str("WebDriver BiDi WebSocket opening write failed before the request was complete"),Self::WriteTimeoutCleanupFailed{..}=>formatter.write_str("failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff")}}} -impl Error for WebDriverBiDiWebSocketOpeningWriteError{fn source(&self)->Option<&(dyn Error+'static)>{match self{Self::WriteTimeoutConfigurationFailed{source,..}|Self::WriteTimedOut{source,..}|Self::WriteFailed{source,..}|Self::WriteTimeoutCleanupFailed{source,..}=>Some(source),_=>None}}} +pub enum WebDriverBiDiWebSocketOpeningWriteError { + /// The requested total write deadline was zero or above the reviewed resource ceiling. + InvalidWriteTimeout { + /// Rejected caller-supplied deadline. + write_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The monotonic total write deadline elapsed before the complete request was written. + WriteDeadlineExceeded { + /// Number of request bytes written before the deadline elapsed. + bytes_written: usize, + }, + /// Applying the remaining operating-system write timeout failed. + WriteTimeoutConfigurationFailed { + /// Number of request bytes already written before configuration failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket write reported timeout or would-block before completion. + WriteTimedOut { + /// Number of request bytes written before the timed-out operation. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A socket write returned zero bytes before the request was complete. + WriteZero { + /// Number of request bytes written before the zero-length write. + bytes_written: usize, + }, + /// A non-recoverable socket write failed before the complete request was emitted. + WriteFailed { + /// Number of request bytes written before the failure. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// Clearing the operation-local socket write timeout failed after all request bytes were sent. + WriteTimeoutCleanupFailed { + /// Number of request bytes already written before cleanup failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, +} -trait OpeningRequestWriter{fn set_write_timeout(&self,timeout:Duration)->io::Result<()>;fn clear_write_timeout(&self)->io::Result<()>;fn write_request_bytes(&mut self,bytes:&[u8])->io::Result;} -impl OpeningRequestWriter for TcpStream{fn set_write_timeout(&self,timeout:Duration)->io::Result<()>{TcpStream::set_write_timeout(self,Some(timeout))}fn clear_write_timeout(&self)->io::Result<()>{TcpStream::set_write_timeout(self,None)}fn write_request_bytes(&mut self,bytes:&[u8])->io::Result{self.write(bytes)}} -fn write_request_with_clock(writer:&mut dyn OpeningRequestWriter,request:&[u8],write_timeout:Duration,now:&mut dyn FnMut()->Instant)->Result{let deadline=now()+write_timeout;let mut bytes_written=0;while bytes_writtenreturn Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero{bytes_written}),Ok(count)=>{bytes_written+=count;if deadline.saturating_duration_since(now()).is_zero(){return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded{bytes_written});}},Err(source) if source.kind()==io::ErrorKind::Interrupted=>continue,Err(source) if matches!(source.kind(),io::ErrorKind::TimedOut|io::ErrorKind::WouldBlock)=>return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut{bytes_written,source}),Err(source)=>return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed{bytes_written,source})}}writer.clear_write_timeout().map_err(|source|WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed{bytes_written,source})?;Ok(bytes_written)} +impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidWriteTimeout { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound", + ), + Self::WriteDeadlineExceeded { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write exceeded its monotonic deadline", + ), + Self::WriteTimeoutConfigurationFailed { .. } => formatter.write_str( + "failed to configure the bounded WebDriver BiDi WebSocket opening write timeout", + ), + Self::WriteTimedOut { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timed out before the request was complete", + ), + Self::WriteZero { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write returned zero before the request was complete", + ), + Self::WriteFailed { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write failed before the request was complete", + ), + Self::WriteTimeoutCleanupFailed { .. } => formatter.write_str( + "failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff", + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketOpeningWriteError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::WriteTimeoutConfigurationFailed { source, .. } + | Self::WriteTimedOut { source, .. } + | Self::WriteFailed { source, .. } + | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), + Self::InvalidWriteTimeout { .. } + | Self::WriteDeadlineExceeded { .. } + | Self::WriteZero { .. } => None, + } + } +} + +trait OpeningRequestWriter { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; + fn clear_write_timeout(&self) -> io::Result<()>; + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; +} + +impl OpeningRequestWriter for TcpStream { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()> { + TcpStream::set_write_timeout(self, Some(timeout)) + } + + fn clear_write_timeout(&self) -> io::Result<()> { + TcpStream::set_write_timeout(self, None) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_request_with_clock( + writer: &mut dyn OpeningRequestWriter, + request: &[u8], + write_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + write_timeout; + let mut bytes_written = 0; + + while bytes_written < request.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, + ); + } + writer.set_write_timeout(remaining).map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written, + source, + } + })?; + + match writer.write_request_bytes(&request[bytes_written..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); + } + Ok(count) => { + bytes_written += count; + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written, + }, + ); + } + } + Err(source) => { + if source.kind() == io::ErrorKind::Interrupted { + continue; + } + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written, + source, + }); + } + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written, + source, + }); + } + } + } + + writer.clear_write_timeout().map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written, + source, + } + })?; + + Ok(bytes_written) +} #[cfg(test)] +#[allow(clippy::expect_used)] mod opening_write_tests { use super::*; + use std::{ + collections::VecDeque, + net::{Shutdown, TcpListener}, + thread, + }; + + use originweave_core::WebDriverBiDiWebSocketEndpoint; + + #[derive(Debug)] + enum WriteAction { + Count(usize), + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeWriter { + timeout_error: Option, + clear_timeout_error: Option, + actions: VecDeque, + } + + impl FakeWriter { + fn new(actions: impl IntoIterator) -> Self { + Self { + timeout_error: None, + clear_timeout_error: None, + actions: actions.into_iter().collect(), + } + } + } + + impl OpeningRequestWriter for FakeWriter { + fn set_write_timeout(&self, _timeout: Duration) -> io::Result<()> { + if let Some(kind) = self.timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn clear_write_timeout(&self) -> io::Result<()> { + if let Some(kind) = self.clear_timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + let action = self + .actions + .pop_front() + .unwrap_or(WriteAction::Count(bytes.len())); + match action { + WriteAction::Count(count) => Ok(count.min(bytes.len())), + WriteAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + impl FrameWriter for FakeWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + let error = if timeout.is_some() { + self.timeout_error + } else { + self.clear_timeout_error + }; + error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) + } + + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write_request_bytes(bytes) + } + } + + #[derive(Clone, Debug)] + enum ReadAction { + Byte(u8), + Count(usize), + End, + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeReader { + actions: VecDeque, + mode_error: Option, + cleanup_error: Option, + } + + impl FakeReader { + fn new(actions: impl IntoIterator) -> Self { + Self { + actions: actions.into_iter().collect(), + mode_error: None, + cleanup_error: None, + } + } + } + + impl OpeningResponseReader for FakeReader { + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { + let error = if nonblocking { + self.mode_error + } else { + self.cleanup_error + }; + error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) + } + + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { + match self.actions.pop_front().unwrap_or(ReadAction::End) { + ReadAction::Byte(byte) => { + bytes[0] = byte; + Ok(1) + } + ReadAction::Count(count) => Ok(count), + ReadAction::End => Ok(0), + ReadAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + fn client_key() -> WebDriverBiDiWebSocketClientKey { + WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ==") + .expect("test client key must be valid") + } + + fn valid_response() -> Vec { + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec() + } + + fn byte_actions(bytes: &[u8]) -> Vec { + bytes.iter().copied().map(ReadAction::Byte).collect() + } + + fn is_malformed_response(response: &[u8], key: &WebDriverBiDiWebSocketClientKey) -> bool { + matches!( + parse_opening_response(response, key), + Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { .. }) + ) + } + + fn read_with_fake( + reader: &mut FakeReader, + now_values: impl IntoIterator, + ) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { + let key = client_key(); + let fallback = Instant::now(); + let mut now_values = now_values.into_iter(); + let mut now = || now_values.next().unwrap_or(fallback); + read_opening_response_with_clock(reader, &key, Duration::from_secs(1), &mut now) + } + + fn read_frame_with_fake( + reader: &mut FakeReader, + now_values: impl IntoIterator, + ) -> Result { + let fallback = Instant::now(); + let mut now_values = now_values.into_iter(); + let mut now = || now_values.next().unwrap_or(fallback); + read_frame_with_clock(reader, Duration::from_secs(1), &mut now) + } + + #[test] + fn parser_accepts_case_insensitive_upgrade_tokens_and_rejects_malformed_headers() { + let key = client_key(); + let response = b"HTTP/1.1 101 Switching Protocols\r\nUpGrAdE: WebSocket\r\nConnection: keep-alive, Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nX-Test: retained\r\n\r\n"; + let parsed = parse_opening_response(response, &key).expect("valid response"); + assert_eq!(parsed.status_code, 101); + assert_eq!(parsed.byte_count, response.len()); + assert!(!is_malformed_response(response, &key)); + let same_length_mismatch = String::from_utf8(response.to_vec()) + .expect("valid response fixture") + .replace( + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", + "s3pPLMBiTxaQ9kYGzzhZRbK+xOoX", + ); + assert!(parse_opening_response(same_length_mismatch.as_bytes(), &key).is_err()); + + let malformed_responses = [ + b"HTTP/1.1 101".to_vec(), + vec![0xff, b'\r', b'\n', b'\r', b'\n'], + b"HTTP/1.1 101\0 Switching Protocols\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nBad Header: value\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n: value\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: web\x01socket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nUpgrade: websocket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nConnection: Upgrade\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nSec-WebSocket-Accept: one\r\nSec-WebSocket-Accept: two\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: h2c\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: keep-alive\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n".to_vec(), + ]; + for response in malformed_responses { + assert!(is_malformed_response(&response, &key)); + } + } + + #[test] + fn bounded_response_reader_covers_deadlines_size_io_and_cleanup() { + let start = Instant::now(); + + let mut valid_reader = FakeReader::new(byte_actions(&valid_response())); + let valid = read_with_fake(&mut valid_reader, [start]); + assert!(valid.is_ok()); + + let mut malformed_reader = FakeReader::new(byte_actions(b"HTTP/1.1 200 OK\r\n\r\n")); + assert!(read_with_fake(&mut malformed_reader, [start]).is_err()); + + let mut interrupted_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) + .chain(byte_actions(&valid_response())), + ); + assert!(read_with_fake(&mut interrupted_reader, [start]).is_ok()); + + let mut mode_error_reader = FakeReader::new([]); + mode_error_reader.mode_error = Some(io::ErrorKind::InvalidInput); + assert!(read_with_fake(&mut mode_error_reader, [start]).is_err()); + + let mut ended_reader = FakeReader::new([ReadAction::End]); + assert!(read_with_fake(&mut ended_reader, [start]).is_err()); + + let mut count_reader = FakeReader::new([ReadAction::Count(2)]); + assert!(read_with_fake(&mut count_reader, [start]).is_err()); + + let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); + assert!(read_with_fake(&mut failed_reader, [start]).is_err()); + + let mut retrying_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::WouldBlock)) + .chain(byte_actions(&valid_response())), + ); + assert!(read_with_fake(&mut retrying_reader, [start]).is_ok()); + + let mut timed_out_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::TimedOut)]); + assert!( + read_with_fake( + &mut timed_out_reader, + [start, start, start + Duration::from_secs(1)] + ) + .is_err() + ); + + let mut deadline_reader = FakeReader::new([ReadAction::End]); + assert!( + read_with_fake( + &mut deadline_reader, + [start, start + Duration::from_secs(1)] + ) + .is_err() + ); + + let mut late_response_reader = FakeReader::new(byte_actions(&valid_response())); + let mut late_response_times = vec![start; valid_response().len() + 1]; + late_response_times.push(start + Duration::from_secs(1)); + assert!(read_with_fake(&mut late_response_reader, late_response_times).is_err()); + + let mut cleanup_reader = FakeReader::new(byte_actions(&valid_response())); + cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); + assert!(read_with_fake(&mut cleanup_reader, [start]).is_err()); + + let mut too_large_reader = FakeReader::new(std::iter::repeat_n( + ReadAction::Byte(b'a'), + MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + )); + assert!(read_with_fake(&mut too_large_reader, [start]).is_err()); + } + + #[test] + fn response_errors_have_deterministic_messages_and_sources() { + let source = io::Error::from(io::ErrorKind::InvalidInput); + let errors = [ + WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { + response_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { + bytes_read: 1, + maximum_bytes: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { + bytes_read: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "test" }, + WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch, + WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { source }, + ]; + for (error, has_source) in errors.iter().zip([ + false, false, false, true, true, true, false, false, false, true, + ]) { + assert!(!error.to_string().is_empty()); + assert_eq!(error.source().is_some(), has_source); + } + } + + #[test] + fn bounded_writer_completes_partial_and_interrupted_writes() { + let mut writer = FakeWriter::new([ + WriteAction::Count(2), + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(3), + ]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start, start]); + let mut now = || times.pop_front().unwrap_or(start); + let result = + write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); + let is_five = |candidate: Result| { + matches!(candidate, Ok(5)) + }; + assert!(is_five(result)); + assert!(!is_five(Ok(4))); + } + + fn join_loopback_server(server: thread::JoinHandle>) -> bool { + match server.join() { + Ok(result) => { + result.expect("loopback server must accept the client"); + false + } + Err(_) => true, + } + } + + #[test] + fn bounded_writer_clears_real_socket_timeout_before_success() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let server = thread::spawn(move || listener.accept().map(|_| ())); + let mut stream = TcpStream::connect(address).expect("test client must connect"); + let start = Instant::now(); + let mut now = || start; + + let request_byte_count = + write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) + .expect("the opening request must be written"); + + assert_eq!(request_byte_count, 7); + assert_eq!( + stream + .write_timeout() + .expect("the socket timeout must be inspectable"), + None + ); + assert!(!join_loopback_server(server)); + } + + #[test] + fn panicked_loopback_server_is_reported() { + let server = thread::spawn(|| -> io::Result<()> { + std::panic::resume_unwind(Box::new("intentional test-only server panic")); + }); + + assert!(join_loopback_server(server)); + } + + #[test] + fn bounded_writer_rejects_cleanup_failure_without_success_handoff() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); + let start = Instant::now(); + let mut now = || start; + + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_cleanup_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + .. + } + ) + ) + }; + assert!(is_cleanup_failure(result)); + assert!(!is_cleanup_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); + } #[test] - fn close_status_code_validation_covers_wire_bounds_and_forbidden_sentinels() { - for code in [999_u16, 1005, 1006, 1015, 5000] { - assert!(!is_valid_close_status_code(code)); + fn bounded_writer_rejects_completion_observed_after_total_deadline() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); + let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_deadline_after_one = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 1 + } + ) + ) + }; + assert!(is_deadline_after_one(result)); + assert!(!is_deadline_after_one(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); + } + + #[test] + fn bounded_writer_classifies_deadline_timeout_zero_and_io_failures() { + let start = Instant::now(); + + let mut deadline_writer = FakeWriter::new([]); + let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); + let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); + let deadline = write_request_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + &mut deadline_now, + ); + let is_deadline_before_write = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 0 + } + ) + ) + }; + assert!(is_deadline_before_write(deadline)); + assert!(!is_deadline_before_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + + let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); + let mut zero_now = || start; + let zero = write_request_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + &mut zero_now, + ); + let is_zero_write = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) + ) + }; + assert!(is_zero_write(zero)); + assert!(!is_zero_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } + ))); + + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut writer = FakeWriter::new([WriteAction::Error(kind)]); + let mut now = || start; + let timed_out = + write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_timed_out = + |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_timed_out(timed_out)); + assert!(!is_timed_out(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + source: io::Error::from(kind), + } + ))); + } + + let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let mut failed_now = || start; + let failed = write_request_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + &mut failed_now, + ); + let is_failed = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_failed(failed)); + assert!(!is_failed(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + + let mut configuration_writer = FakeWriter::new([]); + configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let mut configuration_now = || start; + let configuration = write_request_with_clock( + &mut configuration_writer, + b"x", + Duration::from_secs(1), + &mut configuration_now, + ); + let is_configuration_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + .. + } + ) + ) + }; + assert!(is_configuration_failure(configuration)); + assert!(!is_configuration_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + } + + #[test] + fn opening_write_errors_have_deterministic_messages_and_sources() { + let invalid = WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }; + let deadline = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; + let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }; + let zero = WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 }; + let failed = WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }; + let cleanup = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + + assert!(!invalid.to_string().is_empty()); + assert!(!deadline.to_string().is_empty()); + assert!(!configure.to_string().is_empty()); + assert!(!timed_out.to_string().is_empty()); + assert!(!zero.to_string().is_empty()); + assert!(!failed.to_string().is_empty()); + assert!(!cleanup.to_string().is_empty()); + assert!(invalid.source().is_none()); + assert!(deadline.source().is_none()); + assert!(configure.source().is_some()); + assert!(timed_out.source().is_some()); + assert!(zero.source().is_none()); + assert!(failed.source().is_some()); + assert!(cleanup.source().is_some()); + } + + #[test] + fn frame_codec_reader_writer_and_errors_are_fully_bounded() { + let masking_key = WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]); + assert_eq!(masking_key.as_bytes(), &[0x37, 0xfa, 0x21, 0x3d]); + for payload in [vec![b'x'; 125], vec![b'x'; 126], vec![b'x'; 65_536]] { + let frame = serialize_text_frame(&payload, masking_key); + assert_eq!(frame[0], 0x81); + assert_ne!(frame[1] & 0x80, 0); + let mask_offset = match payload.len() { + 0..=125 => 2, + 126..=65_535 => 4, + _ => 10, + }; + assert_eq!(&frame[mask_offset..mask_offset + 4], masking_key.as_bytes()); + } + + let start = Instant::now(); + let valid = [0x81, 0x01, b'x']; + let mut valid_reader = FakeReader::new(byte_actions(&valid)); + let valid_frame = read_frame_with_fake(&mut valid_reader, [start]).expect("valid frame"); + assert!(valid_frame.fin()); + assert_eq!(valid_frame.opcode(), 0x1); + assert_eq!(valid_frame.payload(), b"x"); + + let mut ping_reader = FakeReader::new([ReadAction::Byte(0x89), ReadAction::Byte(0)]); + let ping = read_frame_with_fake(&mut ping_reader, [start]).expect("ping frame"); + assert!(ping.fin()); + assert_eq!(ping.opcode(), 0x9); + + let mut continuation_reader = + FakeReader::new([ReadAction::Byte(0x00), ReadAction::Byte(0)]); + let continuation = + read_frame_with_fake(&mut continuation_reader, [start]).expect("continuation frame"); + assert!(!continuation.fin()); + assert_eq!(continuation.opcode(), 0); + + let mut extended_16 = FakeReader::new( + byte_actions(&[0x81, 126, 0, 126]) + .into_iter() + .chain([ReadAction::Count(126)]), + ); + assert_eq!( + read_frame_with_fake(&mut extended_16, [start]) + .expect("extended frame") + .payload() + .len(), + 126 + ); + let mut extended_64 = FakeReader::new( + byte_actions(&[0x81, 127, 0, 0, 0, 0, 0, 1, 0, 0]) + .into_iter() + .chain([ReadAction::Count(65_536)]), + ); + assert_eq!( + read_frame_with_fake(&mut extended_64, [start]) + .expect("large extended frame") + .payload() + .len(), + 65_536 + ); + let mut extended_16_error = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(126), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut extended_16_error, [start]).is_err()); + let mut extended_64_error = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(127), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut extended_64_error, [start]).is_err()); + + let mut oversized_header = vec![0x81, 127]; + oversized_header + .extend_from_slice(&((MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64) + 1).to_be_bytes()); + let mut malformed_readers = vec![ + vec![0xc1, 0], + vec![0x09, 0], + vec![0x83, 0], + vec![0x81, 0x80], + vec![0x81, 126, 0, 1], + vec![0x81, 127, 0x80, 0, 0, 0, 0, 0, 0, 0], + vec![0x81, 127, 0, 0, 0, 0, 0, 0, 0xff, 0xff], + vec![0x89, 126, 0, 126], + oversized_header, + ]; + for bytes in malformed_readers.drain(..) { + let mut reader = FakeReader::new(byte_actions(&bytes)); + assert!(read_frame_with_fake(&mut reader, [start]).is_err()); + } + let mut count_reader = FakeReader::new([ReadAction::Count(3)]); + assert!(read_frame_with_fake(&mut count_reader, [start]).is_err()); + let mut ended_reader = FakeReader::new([ReadAction::Byte(0x81), ReadAction::End]); + assert!(read_frame_with_fake(&mut ended_reader, [start]).is_err()); + let mut interrupted_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) + .chain(byte_actions(&valid)), + ); + assert!(read_frame_with_fake(&mut interrupted_reader, [start]).is_ok()); + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut retrying_reader = FakeReader::new( + std::iter::once(ReadAction::Error(kind)).chain(byte_actions(&valid)), + ); + assert!(read_frame_with_fake(&mut retrying_reader, [start]).is_ok()); + } + let mut payload_error_reader = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(1), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut payload_error_reader, [start]).is_err()); + let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); + assert!(read_frame_with_fake(&mut failed_reader, [start]).is_err()); + let mut mode_reader = FakeReader::new([]); + mode_reader.mode_error = Some(io::ErrorKind::InvalidInput); + assert!(read_frame_with_fake(&mut mode_reader, [start]).is_err()); + let mut timeout_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::WouldBlock)]); + assert!( + read_frame_with_fake( + &mut timeout_reader, + [start, start, start + Duration::from_secs(1)] + ) + .is_err() + ); + let mut deadline_reader = FakeReader::new([]); + assert!( + read_frame_with_fake( + &mut deadline_reader, + [start, start + Duration::from_secs(1)] + ) + .is_err() + ); + let mut cleanup_reader = FakeReader::new(byte_actions(&valid)); + cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); + assert!(read_frame_with_fake(&mut cleanup_reader, [start]).is_err()); + + let mut writer = FakeWriter::new([ + WriteAction::Count(1), + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(99), + ]); + let mut now = || start; + assert_eq!( + write_frame_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now) + .expect("frame write"), + 5 + ); + let mut empty_writer = FakeWriter::new([]); + let mut empty_now = || start; + assert_eq!( + write_frame_with_clock( + &mut empty_writer, + b"", + Duration::from_secs(1), + &mut empty_now + ) + .expect("empty frame write"), + 0 + ); + let mut deadline_writer = FakeWriter::new([]); + let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); + let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); + assert!( + write_frame_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + &mut deadline_now + ) + .is_err() + ); + let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); + let mut zero_now = || start; + assert!( + write_frame_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + &mut zero_now + ) + .is_err() + ); + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut writer = FakeWriter::new([WriteAction::Error(kind)]); + let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); + let mut now = || times.pop_front().unwrap_or(start); + assert!( + write_frame_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now) + .is_err() + ); } - for code in [1000_u16, 3000, 4000, 4999] { - assert!(is_valid_close_status_code(code)); + let mut retrying_writer = FakeWriter::new([ + WriteAction::Error(io::ErrorKind::WouldBlock), + WriteAction::Count(1), + ]); + let mut retrying_now = || start; + assert_eq!( + write_frame_with_clock( + &mut retrying_writer, + b"x", + Duration::from_secs(1), + &mut retrying_now + ) + .expect("retrying frame write"), + 1 + ); + let mut interrupted_writer = FakeWriter::new([ + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(1), + ]); + let mut interrupted_now = || start; + assert_eq!( + write_frame_with_clock( + &mut interrupted_writer, + b"x", + Duration::from_secs(1), + &mut interrupted_now + ) + .expect("interrupted frame write"), + 1 + ); + let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let mut failed_now = || start; + assert!( + write_frame_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + &mut failed_now + ) + .is_err() + ); + let mut configuration_writer = FakeWriter::new([]); + configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let mut configuration_now = || start; + assert!( + write_frame_with_clock( + &mut configuration_writer, + b"x", + Duration::from_secs(1), + &mut configuration_now + ) + .is_err() + ); + let mut cleanup_writer = FakeWriter::new([WriteAction::Count(1)]); + cleanup_writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); + let mut cleanup_now = || start; + assert!( + write_frame_with_clock( + &mut cleanup_writer, + b"x", + Duration::from_secs(1), + &mut cleanup_now + ) + .is_err() + ); + + for timeout in [ + Duration::ZERO, + MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1), + ] { + assert!(validate_frame_timeout(timeout).is_err()); } + let errors = [ + WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: 2, + maximum_bytes: 1, + }, + WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketFrameError::FrameEnded { bytes_read: 1 }, + WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "test" }, + WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written: 1 }, + WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + ]; + for (error, has_source) in errors.iter().zip([ + false, false, true, true, true, false, false, true, true, true, false, true, + ]) { + assert!(!error.to_string().is_empty()); + assert_eq!(error.source().is_some(), has_source); + } + } + + #[test] + fn established_frame_write_discards_locally_revoked_streams() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("test server must accept"); + stream + .write_all(&valid_response()) + .expect("test server must write response"); + }); + + let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://{address}/session/01234567-89ab-cdef-0123-456789abcdef" + )) + .expect("test endpoint must be valid"); + let correlated = endpoint + .correlate_session_id("01234567-89ab-cdef-0123-456789abcdef") + .expect("test session must correlate"); + let target = correlated + .into_explicit_connect_target() + .expect("test target must be explicit"); + let connection = + crate::WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("test connection plan must be valid") + .connect() + .expect("test connection must succeed"); + let sent = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) + .expect("test handshake plan must be valid") + .write_opening_request(Duration::from_secs(1)) + .expect("test opening request must be written"); + let established = sent + .read_opening_response(Duration::from_secs(1)) + .expect("test opening response must be valid"); + let _ = established.stream.shutdown(Shutdown::Both); + assert!( + established + .write_text_frame( + "x", + WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]), + Duration::from_secs(1), + ) + .is_err() + ); + assert!(server.join().is_ok()); } } From 21df42e07741b5aaede7f35bec0ab8ed2f082508 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:06:56 -0700 Subject: [PATCH 296/570] fix(network): reject forbidden WebSocket close codes --- .../src/webdriver_bidi_websocket_handshake.rs | 2392 +--------------- .../transport_impl.rs | 2474 +++++++++++++++++ 2 files changed, 2538 insertions(+), 2328 deletions(-) create mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_handshake/transport_impl.rs diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 4e42217f9..82ef2b92c 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -1,268 +1,83 @@ -use std::{ - error::Error, - fmt, - io::{self, Read, Write}, - net::TcpStream, - thread, - time::{Duration, Instant}, -}; +//! Public WebDriver BiDi WebSocket transport façade. +//! +//! The frame transport implementation remains isolated in a private module. This façade preserves +//! the reviewed public API while enforcing RFC 6455 close-status validity before any received Close +//! frame is handed to a caller. + +use std::{fmt, time::Duration}; -use base64::{Engine, engine::general_purpose::STANDARD}; use originweave_core::VerifiedWebDriverBiDiSocketPeer; -use sha1::{Digest, Sha1}; use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; -const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; -const RFC6455_WEBSOCKET_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; -const MAX_WEBSOCKET_OPENING_RESPONSE_BYTES: usize = 16 * 1024; -const MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES: usize = 1024 * 1024; - -/// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. -/// -/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. The request is -/// already bounded before this budget is applied. Callers may choose any smaller nonzero deadline. -pub const MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT: Duration = Duration::from_secs(5); - -/// Maximum wall-clock budget accepted for reading one bounded WebSocket opening response. -/// -/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. Callers may -/// choose any smaller nonzero deadline. -pub const MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); - -/// Maximum bytes admitted while reading one WebSocket HTTP opening response. -/// -/// The response is consumed only through its terminating `CRLF CRLF`; WebSocket frames are not -/// read or interpreted by this boundary. -pub const MAX_WEBSOCKET_OPENING_RESPONSE_SIZE: usize = MAX_WEBSOCKET_OPENING_RESPONSE_BYTES; - -/// Maximum payload bytes admitted for one WebSocket frame. -pub const MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE: usize = MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES; - -/// Maximum wall-clock budget accepted for one bounded WebSocket frame I/O operation. -pub const MAX_WEBSOCKET_FRAME_TIMEOUT: Duration = Duration::from_secs(5); - -fn is_base64_data_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') -} +#[path = "webdriver_bidi_websocket_handshake/transport_impl.rs"] +mod transport_impl; -fn is_canonical_16_byte_base64(value: &str) -> bool { - let bytes = value.as_bytes(); - bytes.len() == WEBSOCKET_CLIENT_KEY_LENGTH - && bytes[..22].iter().copied().all(is_base64_data_byte) - && matches!(bytes[21], b'A' | b'Q' | b'g' | b'w') - && bytes[22] == b'=' - && bytes[23] == b'=' -} +pub use transport_impl::{ + MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_FRAME_TIMEOUT, + MAX_WEBSOCKET_OPENING_RESPONSE_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, + MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrame, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningWriteError, +}; -/// Deterministic failures while preparing one WebDriver BiDi RFC 6455 opening request. -#[derive(Debug, Eq, PartialEq)] -pub enum WebDriverBiDiWebSocketHandshakeError { - /// The supplied client key was not the canonical base64 representation of exactly 16 bytes. - InvalidClientKey, - /// The verified WebDriver BiDi target requires TLS before a WebSocket opening request is sent. - TlsRequired, -} +/// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. +pub struct WebDriverBiDiWebSocketHandshakePlan(transport_impl::WebDriverBiDiWebSocketHandshakePlan); -impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { +impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidClientKey => formatter.write_str( - "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes", - ), - Self::TlsRequired => formatter.write_str( - "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request", - ), - } + self.0.fmt(formatter) } } -impl Error for WebDriverBiDiWebSocketHandshakeError {} - -/// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. -/// -/// RFC 6455 requires `Sec-WebSocket-Key` to be a nonce of 16 bytes encoded with base64. This type -/// validates only the canonical wire representation, including zero padding bits. It does not -/// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce -/// for each connection attempt. -#[derive(Debug, Eq, PartialEq)] -pub struct WebDriverBiDiWebSocketClientKey(String); - -impl WebDriverBiDiWebSocketClientKey { - /// Admit one canonical base64 client key representing exactly 16 bytes. - pub fn new(value: &str) -> Result { - if !is_canonical_16_byte_base64(value) { - return Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey); - } - Ok(Self(value.to_owned())) - } - - /// Borrow the exact canonical value for `Sec-WebSocket-Key` serialization. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} - -/// Caller-supplied RFC 6455 mask key for one client-to-server frame. -/// -/// RFC 6455 requires every client frame to carry a fresh, unpredictable four-byte key. This type -/// preserves that requirement at the API boundary without inventing an entropy source; callers must -/// obtain a fresh key from an approved randomness source for every frame. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct WebDriverBiDiWebSocketMaskKey([u8; 4]); - -impl WebDriverBiDiWebSocketMaskKey { - /// Admit one four-byte caller-supplied frame mask key. - #[must_use] - pub const fn new(value: [u8; 4]) -> Self { - Self(value) - } - - /// Borrow the exact four-byte key used on the wire. - #[must_use] - pub const fn as_bytes(&self) -> &[u8; 4] { - &self.0 - } -} - -/// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. -/// -/// The plan consumes the verified TCP connection so the opening request cannot be detached from the -/// socket peer/session evidence that authorized its exact loopback destination. It serializes only -/// the fixed WebSocket version-13 request required for the admitted `/session/` resource -/// and retains the exact client key required to validate a later `Sec-WebSocket-Accept` response. -/// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary -/// before any WebSocket bytes may be written. -/// -/// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` -/// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or -/// Agent-authority grant. -#[derive(Debug)] -pub struct WebDriverBiDiWebSocketHandshakePlan { - connection: WebDriverBiDiTcpConnection, - client_key: WebDriverBiDiWebSocketClientKey, - request: Vec, -} - impl WebDriverBiDiWebSocketHandshakePlan { /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. pub fn new( connection: WebDriverBiDiTcpConnection, client_key: WebDriverBiDiWebSocketClientKey, ) -> Result { - if connection.verified_peer().requires_tls() { - return Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired); - } - - let peer = connection.verified_peer(); - let request = format!( - "GET /session/{} HTTP/1.1\r\nHost: {}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n", - peer.session_id(), - peer.socket_addr(), - client_key.as_str(), - ) - .into_bytes(); - - Ok(Self { - connection, - client_key, - request, - }) + transport_impl::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key).map(Self) } /// Borrow the exact serialized RFC 6455 opening-request bytes. #[must_use] pub fn request_bytes(&self) -> &[u8] { - &self.request + self.0.request_bytes() } /// Borrow the exact client key that a later server-handshake validator must correlate. #[must_use] pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { - &self.client_key + self.0.client_key() } /// Borrow the exact peer/session evidence already verified before request construction. #[must_use] pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { - self.connection.verified_peer() + self.0.verified_peer() } /// Write the complete bounded opening request on the exact verified stream within one deadline. - /// - /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an - /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the - /// destination, or retries after any other I/O failure. A partial write that cannot finish before - /// the same monotonic deadline is an error and yields no successful handoff. Before success, the - /// operation-local socket write timeout is cleared so the next separately reviewed protocol stage - /// cannot inherit stale timeout authority. Success preserves the live stream, exact transport - /// evidence, and client key for a separately reviewed server handshake validator. It does not - /// read or validate the server response and therefore does not establish WebSocket protocol state - /// or browser/Agent authority. pub fn write_opening_request( self, write_timeout: Duration, ) -> Result { - if write_timeout.is_zero() || write_timeout > MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { - write_timeout, - maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, - }, - ); - } - - let Self { - connection, - client_key, - request, - } = self; - let (mut stream, transport_evidence) = connection.into_parts(); - let mut now = Instant::now; - let request_byte_count = - write_request_with_clock(&mut stream, &request, write_timeout, &mut now)?; - - Ok(WebDriverBiDiWebSocketOpeningRequestSent { - stream, - transport_evidence, - client_key, - request_byte_count, - write_timeout, - }) + self.0 + .write_opening_request(write_timeout) + .map(WebDriverBiDiWebSocketOpeningRequestSent) } } -/// A live verified stream after the complete client opening request has been written. -/// -/// This state proves only that the exact bounded RFC 6455 client request reached the operating -/// system's verified TCP stream before the configured deadline and that this operation's socket write -/// timeout was cleared before handoff. It deliberately does not claim that the peer returned `101 -/// Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket is established, or -/// that the peer is the expected Chromium/ChromeDriver process. Those remain separate fail-closed -/// boundaries. -pub struct WebDriverBiDiWebSocketOpeningRequestSent { - pub(crate) stream: TcpStream, - transport_evidence: WebDriverBiDiTcpConnectionEvidence, - client_key: WebDriverBiDiWebSocketClientKey, - request_byte_count: usize, - write_timeout: Duration, -} +/// A live verified stream after the complete client WebSocket opening request has been written. +pub struct WebDriverBiDiWebSocketOpeningRequestSent( + transport_impl::WebDriverBiDiWebSocketOpeningRequestSent, +); impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") - .field("stream_local_addr", &self.stream.local_addr().ok()) - .field("transport_evidence", &self.transport_evidence) - .field( - "client_key", - &"", - ) - .field("request_byte_count", &self.request_byte_count) - .field("write_timeout", &self.write_timeout) - .finish() + self.0.fmt(formatter) } } @@ -270,103 +85,45 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { /// Borrow the exact verified transport evidence retained with this live stream. #[must_use] pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { - &self.transport_evidence + self.0.transport_evidence() } /// Borrow the exact client key required to validate the later server accept value. #[must_use] pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { - &self.client_key + self.0.client_key() } /// Return the exact number of opening-request bytes written before success was emitted. #[must_use] pub const fn request_byte_count(&self) -> usize { - self.request_byte_count + self.0.request_byte_count() } /// Return the total write deadline configured for this opening request. #[must_use] pub const fn write_timeout(&self) -> Duration { - self.write_timeout + self.0.write_timeout() } /// Read and validate the bounded RFC 6455 server opening response on this exact stream. - /// - /// Success proves only an HTTP/1.1 `101 Switching Protocols` response with the required - /// `Upgrade`, `Connection`, and client-key-correlated `Sec-WebSocket-Accept` headers. The - /// response body, WebSocket frames, browser process identity, TLS, and browser/Agent authority - /// remain separate boundaries. pub fn read_opening_response( self, response_timeout: Duration, ) -> Result { - if response_timeout.is_zero() || response_timeout > MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { - response_timeout, - maximum_timeout: MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, - }, - ); - } - - let Self { - mut stream, - transport_evidence, - client_key, - request_byte_count, - write_timeout, - } = self; - let mut now = Instant::now; - let (response_status, response_byte_count) = - read_opening_response_with_clock(&mut stream, &client_key, response_timeout, &mut now)?; - - Ok(WebDriverBiDiWebSocketEstablished { - stream, - transport_evidence, - client_key, - response_status, - response_byte_count, - response_timeout, - request_byte_count, - write_timeout, - }) + self.0 + .read_opening_response(response_timeout) + .map(WebDriverBiDiWebSocketEstablished) } } /// A live verified stream after both RFC 6455 opening messages were validated. -/// -/// This state does not implement WebSocket framing or grant browser, page, policy, or Agent -/// authority. It retains the exact transport evidence and client key so later protocol stages can -/// remain correlated with the verified peer and opening handshake. -pub struct WebDriverBiDiWebSocketEstablished { - pub(crate) stream: TcpStream, - transport_evidence: WebDriverBiDiTcpConnectionEvidence, - client_key: WebDriverBiDiWebSocketClientKey, - response_status: u16, - response_byte_count: usize, - response_timeout: Duration, - request_byte_count: usize, - write_timeout: Duration, -} +pub struct WebDriverBiDiWebSocketEstablished(transport_impl::WebDriverBiDiWebSocketEstablished); impl fmt::Debug for WebDriverBiDiWebSocketEstablished { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("WebDriverBiDiWebSocketEstablished") - .field("stream_local_addr", &self.stream.local_addr().ok()) - .field("transport_evidence", &self.transport_evidence) - .field( - "client_key", - &"", - ) - .field("response_status", &self.response_status) - .field("response_byte_count", &self.response_byte_count) - .field("response_timeout", &self.response_timeout) - .field("request_byte_count", &self.request_byte_count) - .field("write_timeout", &self.write_timeout) - .finish() + self.0.fmt(formatter) } } @@ -374,2101 +131,80 @@ impl WebDriverBiDiWebSocketEstablished { /// Borrow the exact verified transport evidence retained with this live stream. #[must_use] pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { - &self.transport_evidence + self.0.transport_evidence() } /// Borrow the exact client key correlated with the validated server accept value. #[must_use] pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { - &self.client_key + self.0.client_key() } /// Return the validated HTTP status code, currently always `101` on success. #[must_use] pub const fn response_status(&self) -> u16 { - self.response_status + self.0.response_status() } /// Return the number of HTTP opening-response bytes consumed through its header terminator. #[must_use] pub const fn response_byte_count(&self) -> usize { - self.response_byte_count + self.0.response_byte_count() } /// Return the total response deadline configured for this opening response. #[must_use] pub const fn response_timeout(&self) -> Duration { - self.response_timeout + self.0.response_timeout() } /// Return the number of request bytes written before the response was read. #[must_use] pub const fn request_byte_count(&self) -> usize { - self.request_byte_count + self.0.request_byte_count() } /// Return the total write deadline configured for the preceding opening request. #[must_use] pub const fn write_timeout(&self) -> Duration { - self.write_timeout + self.0.write_timeout() } /// Write one unfragmented, masked UTF-8 text frame on this verified stream. - /// - /// The operation consumes the established state and returns it only after the complete frame - /// is written and the temporary socket timeout is cleared. The caller must provide a fresh, - /// unpredictable masking key for this frame; it is never exposed in evidence or debug output. - /// This method does not translate JSON, create a BiDi session, or grant browser/Agent authority. pub fn write_text_frame( self, text: &str, masking_key: WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { - validate_frame_timeout(frame_timeout)?; - if text.len() > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES { - return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { - payload_bytes: text.len(), - maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES, - }); - } - - let frame = serialize_text_frame(text.as_bytes(), masking_key); - let Self { - mut stream, - transport_evidence, - client_key, - response_status, - response_byte_count, - response_timeout, - request_byte_count, - write_timeout, - } = self; - let mut now = Instant::now; - write_frame_with_clock(&mut stream, &frame, frame_timeout, &mut now)?; - Ok(Self { - stream, - transport_evidence, - client_key, - response_status, - response_byte_count, - response_timeout, - request_byte_count, - write_timeout, - }) + self.0 + .write_text_frame(text, masking_key, frame_timeout) + .map(Self) } - /// Read one bounded RFC 6455 frame from this verified stream. - /// - /// Server-to-client frames must be unmasked. Data and continuation frames are returned one at - /// a time so a later message layer can enforce fragmentation and JSON semantics; control frames - /// are returned to that layer for protocol handling. Reserved bits/opcodes, oversized payloads, - /// noncanonical lengths, and incomplete reads fail closed. Close frames additionally enforce the - /// RFC 6455 payload shape and UTF-8 reason contract before the frame is returned. No frame grants - /// browser/Agent authority. + /// Read one bounded RFC 6455 frame and reject close status codes forbidden on the wire. pub fn read_frame( self, frame_timeout: Duration, ) -> Result<(Self, WebDriverBiDiWebSocketFrame), WebDriverBiDiWebSocketFrameError> { - validate_frame_timeout(frame_timeout)?; - let Self { - mut stream, - transport_evidence, - client_key, - response_status, - response_byte_count, - response_timeout, - request_byte_count, - write_timeout, - } = self; - let mut now = Instant::now; - let frame = read_frame_with_clock(&mut stream, frame_timeout, &mut now)?; - Ok(( - Self { - stream, - transport_evidence, - client_key, - response_status, - response_byte_count, - response_timeout, - request_byte_count, - write_timeout, - }, - frame, - )) - } -} - -/// One validated WebSocket frame received from the established peer. -#[derive(Debug, Eq, PartialEq)] -pub struct WebDriverBiDiWebSocketFrame { - fin: bool, - opcode: u8, - payload: Vec, -} - -impl WebDriverBiDiWebSocketFrame { - /// Return whether this is the final frame in its message. - #[must_use] - pub const fn fin(&self) -> bool { - self.fin - } - - /// Return the RFC 6455 opcode without interpreting application semantics. - #[must_use] - pub const fn opcode(&self) -> u8 { - self.opcode - } - - /// Borrow the bounded, unmasked application payload. - #[must_use] - pub fn payload(&self) -> &[u8] { - &self.payload - } -} - -fn validate_frame_timeout(frame_timeout: Duration) -> Result<(), WebDriverBiDiWebSocketFrameError> { - if frame_timeout.is_zero() || frame_timeout > MAX_WEBSOCKET_FRAME_TIMEOUT { - return Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { - frame_timeout, - maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, - }); - } - Ok(()) -} - -/// Fail-closed errors while reading or writing one bounded WebSocket frame. -#[derive(Debug)] -pub enum WebDriverBiDiWebSocketFrameError { - /// The requested frame I/O deadline was zero or above the reviewed resource ceiling. - InvalidFrameTimeout { - /// Rejected caller-supplied deadline. - frame_timeout: Duration, - /// Maximum reviewed deadline accepted by this boundary. - maximum_timeout: Duration, - }, - /// The frame payload exceeded the reviewed memory ceiling. - FrameTooLarge { - /// Rejected payload length in bytes. - payload_bytes: usize, - /// Maximum payload length admitted by this boundary. - maximum_bytes: usize, - }, - /// Applying the operation-local nonblocking read mode failed. - FrameReadModeConfigurationFailed { - /// Underlying operating-system error. - source: io::Error, - }, - /// A bounded socket read timed out before the frame was complete. - FrameReadTimedOut { - /// Number of frame bytes consumed before timeout. - bytes_read: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A non-recoverable socket read failed before the frame was complete. - FrameReadFailed { - /// Number of frame bytes consumed before failure. - bytes_read: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// The peer ended the stream before the frame was complete. - FrameEnded { - /// Number of frame bytes consumed before EOF. - bytes_read: usize, - }, - /// The frame header or RFC 6455 control-frame payload violated the protocol contract. - MalformedFrame { - /// Stable, non-secret reason for rejection. - reason: &'static str, - }, - /// Applying the operation-local write timeout failed. - FrameWriteModeConfigurationFailed { - /// Number of frame bytes already written before configuration failed. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A bounded socket write timed out before the frame was complete. - FrameWriteTimedOut { - /// Number of frame bytes written before timeout. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A non-recoverable socket write failed before the frame was complete. - FrameWriteFailed { - /// Number of frame bytes written before failure. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// The stream reported zero progress before the frame was complete. - FrameWriteZero { - /// Number of frame bytes written before zero progress. - bytes_written: usize, - }, - /// Clearing the temporary write timeout failed before handoff. - FrameWriteCleanupFailed { - /// Underlying operating-system error. - source: io::Error, - }, -} - -impl fmt::Display for WebDriverBiDiWebSocketFrameError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidFrameTimeout { .. } => formatter - .write_str("WebDriver BiDi WebSocket frame timeout is outside the reviewed bound"), - Self::FrameTooLarge { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame payload exceeded its bound") - } - Self::FrameReadModeConfigurationFailed { .. } => { - formatter.write_str("failed to configure bounded WebSocket frame reads") - } - Self::FrameReadTimedOut { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame read timed out") - } - Self::FrameReadFailed { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame read failed") - } - Self::FrameEnded { .. } => { - formatter.write_str("WebDriver BiDi WebSocket peer ended the frame stream") - } - Self::MalformedFrame { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame was malformed") - } - Self::FrameWriteModeConfigurationFailed { .. } => { - formatter.write_str("failed to configure bounded WebSocket frame writes") - } - Self::FrameWriteTimedOut { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame write timed out") - } - Self::FrameWriteFailed { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame write failed") - } - Self::FrameWriteZero { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame write made no progress") - } - Self::FrameWriteCleanupFailed { .. } => { - formatter.write_str("failed to clear the WebDriver BiDi WebSocket frame timeout") - } - } + let (established, frame) = self.0.read_frame(frame_timeout)?; + validate_close_status_code(&frame)?; + Ok((Self(established), frame)) } } -impl Error for WebDriverBiDiWebSocketFrameError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::FrameReadModeConfigurationFailed { source } - | Self::FrameReadTimedOut { source, .. } - | Self::FrameReadFailed { source, .. } - | Self::FrameWriteModeConfigurationFailed { source, .. } - | Self::FrameWriteTimedOut { source, .. } - | Self::FrameWriteFailed { source, .. } - | Self::FrameWriteCleanupFailed { source } => Some(source), - Self::InvalidFrameTimeout { .. } - | Self::FrameTooLarge { .. } - | Self::FrameEnded { .. } - | Self::MalformedFrame { .. } - | Self::FrameWriteZero { .. } => None, - } - } -} - -/// Fail-closed errors while reading one bounded WebDriver BiDi WebSocket opening response. -#[derive(Debug)] -pub enum WebDriverBiDiWebSocketHandshakeResponseError { - /// The requested total response deadline was zero or above the reviewed resource ceiling. - InvalidResponseTimeout { - /// Rejected caller-supplied deadline. - response_timeout: Duration, - /// Maximum reviewed deadline accepted by this boundary. - maximum_timeout: Duration, - }, - /// The monotonic total response deadline elapsed before validation completed. - ResponseDeadlineExceeded { - /// Number of response bytes consumed before the deadline elapsed. - bytes_read: usize, - }, - /// The response exceeded the reviewed header-size ceiling before its terminator was found. - ResponseTooLarge { - /// Number of response bytes consumed before rejection. - bytes_read: usize, - /// Maximum response bytes admitted by this boundary. - maximum_bytes: usize, - }, - /// Applying the operation-local nonblocking read mode failed. - ResponseReadModeConfigurationFailed { - /// Number of response bytes consumed before configuration failed. - bytes_read: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A bounded socket read timed out before the opening response was complete. - ResponseReadTimedOut { - /// Number of response bytes consumed before the timed-out operation. - bytes_read: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A non-recoverable socket read failed before the opening response was complete. - ResponseReadFailed { - /// Number of response bytes consumed before the failure. - bytes_read: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// The peer closed the stream before sending a complete HTTP header block. - ResponseEndedBeforeHeaders { - /// Number of response bytes consumed before the peer closed the stream. - bytes_read: usize, - }, - /// The HTTP response was not a valid, required WebSocket opening response. - MalformedResponse { - /// Stable, non-secret reason for the rejected response shape. - reason: &'static str, - }, - /// The response's `Sec-WebSocket-Accept` did not correlate with the sent client key. - AcceptMismatch, - /// Restoring blocking mode failed after validation. - ReadModeCleanupFailed { - /// Underlying operating-system error. - source: io::Error, - }, -} - -impl fmt::Display for WebDriverBiDiWebSocketHandshakeResponseError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidResponseTimeout { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response timeout is outside the reviewed bound", - ), - Self::ResponseDeadlineExceeded { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response exceeded its monotonic deadline", - ), - Self::ResponseTooLarge { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response exceeded its bounded header size", - ), - Self::ResponseReadModeConfigurationFailed { .. } => formatter.write_str( - "failed to configure bounded nonblocking WebDriver BiDi WebSocket response reads", - ), - Self::ResponseReadTimedOut { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response timed out before completion", - ), - Self::ResponseReadFailed { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response read failed before completion", - ), - Self::ResponseEndedBeforeHeaders { .. } => formatter.write_str( - "WebDriver BiDi WebSocket peer ended the stream before completing response headers", - ), - Self::MalformedResponse { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response was malformed or missing a required header", - ), - Self::AcceptMismatch => formatter.write_str( - "WebDriver BiDi WebSocket opening response accept value did not match the client key", - ), - Self::ReadModeCleanupFailed { .. } => formatter.write_str( - "failed to restore blocking WebDriver BiDi WebSocket response reads before handoff", - ), - } - } -} - -impl Error for WebDriverBiDiWebSocketHandshakeResponseError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::ResponseReadModeConfigurationFailed { source, .. } - | Self::ResponseReadTimedOut { source, .. } - | Self::ResponseReadFailed { source, .. } - | Self::ReadModeCleanupFailed { source } => Some(source), - Self::InvalidResponseTimeout { .. } - | Self::ResponseDeadlineExceeded { .. } - | Self::ResponseTooLarge { .. } - | Self::ResponseEndedBeforeHeaders { .. } - | Self::MalformedResponse { .. } - | Self::AcceptMismatch => None, - } - } -} - -struct ParsedOpeningResponse { - status_code: u16, - byte_count: usize, -} - -fn expected_accept_value(client_key: &WebDriverBiDiWebSocketClientKey) -> String { - let mut digest = Sha1::new(); - digest.update(client_key.as_str().as_bytes()); - digest.update(RFC6455_WEBSOCKET_GUID); - STANDARD.encode(digest.finalize()) -} - -fn is_http_token_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() - || matches!( - byte, - b'!' | b'#' - | b'$' - | b'%' - | b'&' - | b'\'' - | b'*' - | b'+' - | b'-' - | b'.' - | b'^' - | b'_' - | b'`' - | b'|' - | b'~' - ) -} - -fn has_header_token(value: &str, expected: &str) -> bool { - value - .split(',') - .map(str::trim) - .any(|token| token.eq_ignore_ascii_case(expected)) -} - -#[allow(clippy::collapsible_if)] -fn parse_opening_response( - response: &[u8], - client_key: &WebDriverBiDiWebSocketClientKey, -) -> Result { - if !response.ends_with(b"\r\n\r\n") { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response is missing its CRLF header terminator", - }, - ); - } - let response_text = std::str::from_utf8(response).map_err(|_| { - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response headers are not valid UTF-8", - } - })?; - let header_text = &response_text[..response_text.len() - 4]; - let (status_line, header_lines) = header_text - .split_once("\r\n") - .map_or((header_text, ""), |(line, rest)| (line, rest)); - if status_line.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "status line contains a control byte", - }, - ); - } - let status_code = status_line - .strip_prefix("HTTP/1.1 ") - .and_then(|rest| rest.split_whitespace().next()) - .and_then(|value| value.parse::().ok()); - if status_code != Some(101) { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "status line is not HTTP/1.1 101", - }, - ); - } - - let mut upgrade_has_websocket = false; - let mut connection_has_upgrade = false; - let mut accept = None; - for line in header_lines.split("\r\n") { - if line.is_empty() - || line - .as_bytes() - .first() - .is_some_and(|byte| matches!(byte, b' ' | b'\t')) - { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "header line is empty or folded", - }, - ); - } - let (name, value) = line.split_once(':').ok_or( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "header line has no colon", - }, - )?; - if name.is_empty() || !name.bytes().all(is_http_token_byte) { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "header name is not an HTTP token", - }, - ); - } - let value = value.trim_matches([' ', '\t']); - if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "header value contains a control byte", - }, - ); - } - if name.eq_ignore_ascii_case("upgrade") { - upgrade_has_websocket |= has_header_token(value, "websocket"); - } else if name.eq_ignore_ascii_case("connection") { - connection_has_upgrade |= has_header_token(value, "upgrade"); - } else if name.eq_ignore_ascii_case("sec-websocket-accept") { - if accept.is_some() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response repeats the Sec-WebSocket-Accept header", - }, - ); - } - accept = Some(value); - } - } - - if !upgrade_has_websocket { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "Upgrade header does not contain websocket", - }, - ); - } - if !connection_has_upgrade { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "Connection header does not contain Upgrade", - }, - ); - } - let Some(accept) = accept else { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response has no Sec-WebSocket-Accept header", - }, - ); - }; - if accept != expected_accept_value(client_key) { - return Err(WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch); - } - - Ok(ParsedOpeningResponse { - status_code: 101, - byte_count: response.len(), - }) -} - -trait OpeningResponseReader { - fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()>; - fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result; -} - -impl OpeningResponseReader for TcpStream { - fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { - TcpStream::set_nonblocking(self, nonblocking) - } - - fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { - self.read(bytes) - } -} - -fn serialize_text_frame(payload: &[u8], masking_key: WebDriverBiDiWebSocketMaskKey) -> Vec { - let mut frame = Vec::with_capacity(payload.len() + 14); - frame.push(0x81); - match payload.len() { - 0..=125 => frame.push(0x80 | payload.len() as u8), - 126..=65_535 => { - frame.push(0x80 | 126); - frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - } - length => { - frame.push(0x80 | 127); - frame.extend_from_slice(&(length as u64).to_be_bytes()); - } - } - frame.extend_from_slice(masking_key.as_bytes()); - frame.extend( - payload.iter().enumerate().map(|(index, byte)| { - byte ^ masking_key.as_bytes()[index % masking_key.as_bytes().len()] - }), - ); - frame -} - -trait FrameWriter { - fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; - fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result; -} - -impl FrameWriter for TcpStream { - fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { - TcpStream::set_write_timeout(self, timeout) - } - - fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { - self.write(bytes) - } -} - -fn write_frame_with_clock( - writer: &mut dyn FrameWriter, - frame: &[u8], - frame_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result { - let deadline = now() + frame_timeout; - let mut bytes_written = 0; - while bytes_written < frame.len() { - let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { - bytes_written, - source: io::Error::new(io::ErrorKind::TimedOut, "frame write deadline elapsed"), - }); - } - writer - .set_write_timeout(Some(remaining)) - .map_err(|source| { - WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { - bytes_written, - source, - } - })?; - match writer.write_frame_bytes(&frame[bytes_written..]) { - Ok(0) => { - return Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written }); - } - Ok(written) => bytes_written += written, - Err(source) => { - if source.kind() == io::ErrorKind::Interrupted { - continue; - } - if matches!( - source.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) { - if deadline.saturating_duration_since(now()).is_zero() { - return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { - bytes_written, - source, - }); - } - thread::sleep(Duration::from_millis(1)); - continue; - } - return Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { - bytes_written, - source, - }); - } - } +fn validate_close_status_code( + frame: &WebDriverBiDiWebSocketFrame, +) -> Result<(), WebDriverBiDiWebSocketFrameError> { + if frame.opcode() != 0x8 || frame.payload().len() < 2 { + return Ok(()); } - writer - .set_write_timeout(None) - .map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { source })?; - Ok(bytes_written) -} -fn read_frame_with_clock( - reader: &mut dyn OpeningResponseReader, - frame_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result { - let deadline = now() + frame_timeout; - reader.set_nonblocking(true).map_err(|source| { - WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { source } - })?; - let mut bytes_read = 0; - let mut header = [0_u8; 2]; - read_frame_bytes_with_clock(reader, &mut header, &mut bytes_read, deadline, now)?; - let first = header[0]; - let second = header[1]; - if first & 0x70 != 0 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "reserved frame bits are not negotiated", - }); - } - let fin = first & 0x80 != 0; - let opcode = first & 0x0f; - match opcode { - 0x0..=0x2 => {} - 0x8..=0xa => { - if !fin { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "control frames must not be fragmented", - }); - } - } - _ => { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "frame opcode is reserved or unsupported", - }); - } - } - if second & 0x80 != 0 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "server-to-client frames must not be masked", - }); - } - let length_code = second & 0x7f; - let payload_length = match length_code { - 0..=125 => u64::from(length_code), - 126 => { - let mut extended = [0_u8; 2]; - read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; - let length = u64::from(u16::from_be_bytes(extended)); - if length < 126 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "frame length encoding is not minimal", - }); - } - length - } - _ => { - let mut extended = [0_u8; 8]; - read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; - if extended[0] & 0x80 != 0 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "frame length uses the reserved high bit", - }); - } - let length = u64::from_be_bytes(extended); - if length < 65_536 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "frame length encoding is not minimal", - }); - } - length - } - }; - if payload_length > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64 { - return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { - payload_bytes: payload_length.min(usize::MAX as u64) as usize, - maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES, - }); - } - if opcode >= 0x8 && payload_length > 125 { + let status_code = u16::from_be_bytes([frame.payload()[0], frame.payload()[1]]); + if !(1000..=4999).contains(&status_code) || matches!(status_code, 1005 | 1006 | 1015) { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "control frame payload exceeds 125 bytes", + reason: "Close frame status code is not valid on the wire", }); } - let payload_length = payload_length as usize; - let mut payload = vec![0_u8; payload_length]; - read_frame_bytes_with_clock(reader, &mut payload, &mut bytes_read, deadline, now)?; - if opcode == 0x8 { - if payload.len() == 1 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "Close frame payload must be empty or begin with a two-byte status code", - }); - } - if payload.len() > 1 && std::str::from_utf8(&payload[2..]).is_err() { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "Close frame reason is not valid UTF-8", - }); - } - } - reader.set_nonblocking(false).map_err(|source| { - WebDriverBiDiWebSocketFrameError::FrameReadFailed { bytes_read, source } - })?; - Ok(WebDriverBiDiWebSocketFrame { - fin, - opcode, - payload, - }) -} - -fn read_frame_bytes_with_clock( - reader: &mut dyn OpeningResponseReader, - destination: &mut [u8], - bytes_read: &mut usize, - deadline: Instant, - now: &mut dyn FnMut() -> Instant, -) -> Result<(), WebDriverBiDiWebSocketFrameError> { - let mut offset = 0; - while offset < destination.len() { - let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { - bytes_read: *bytes_read, - source: io::Error::new(io::ErrorKind::TimedOut, "frame read deadline elapsed"), - }); - } - match reader.read_response_bytes(&mut destination[offset..]) { - Ok(0) => { - return Err(WebDriverBiDiWebSocketFrameError::FrameEnded { - bytes_read: *bytes_read, - }); - } - Ok(read) if read > destination.len() - offset => { - return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { - bytes_read: *bytes_read, - source: io::Error::new( - io::ErrorKind::InvalidData, - "frame reader returned more bytes than requested", - ), - }); - } - Ok(read) => { - offset += read; - *bytes_read += read; - } - Err(source) if source.kind() == io::ErrorKind::Interrupted => {} - Err(source) - if matches!( - source.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) => - { - if deadline.saturating_duration_since(now()).is_zero() { - return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { - bytes_read: *bytes_read, - source, - }); - } - thread::sleep(Duration::from_millis(1)); - } - Err(source) => { - return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { - bytes_read: *bytes_read, - source, - }); - } - } - } Ok(()) } - -fn read_opening_response_with_clock( - reader: &mut dyn OpeningResponseReader, - client_key: &WebDriverBiDiWebSocketClientKey, - response_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { - let deadline = now() + response_timeout; - let mut response = Vec::new(); - - reader.set_nonblocking(true).map_err(|source| { - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { - bytes_read: 0, - source, - } - })?; - - loop { - let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { - bytes_read: response.len(), - }, - ); - } - if response.len() >= MAX_WEBSOCKET_OPENING_RESPONSE_BYTES { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { - bytes_read: response.len(), - maximum_bytes: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, - }, - ); - } - let mut byte = [0_u8; 1]; - match reader.read_response_bytes(&mut byte) { - Ok(0) => { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { - bytes_read: response.len(), - }, - ); - } - Ok(1) => { - response.push(byte[0]); - if response.ends_with(b"\r\n\r\n") { - if deadline.saturating_duration_since(now()).is_zero() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { - bytes_read: response.len(), - }, - ); - } - let parsed = parse_opening_response(&response, client_key)?; - reader.set_nonblocking(false).map_err(|source| { - WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { - source, - } - })?; - return Ok((parsed.status_code, parsed.byte_count)); - } - } - Ok(_) => { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { - bytes_read: response.len(), - source: io::Error::new( - io::ErrorKind::InvalidData, - "response reader returned more bytes than requested", - ), - }, - ); - } - Err(source) if source.kind() == io::ErrorKind::Interrupted => {} - Err(source) - if matches!( - source.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) => - { - if deadline.saturating_duration_since(now()).is_zero() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { - bytes_read: response.len(), - source, - }, - ); - } - thread::sleep(Duration::from_millis(1)); - } - Err(source) => { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { - bytes_read: response.len(), - source, - }, - ); - } - } - } -} - -/// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. -#[derive(Debug)] -pub enum WebDriverBiDiWebSocketOpeningWriteError { - /// The requested total write deadline was zero or above the reviewed resource ceiling. - InvalidWriteTimeout { - /// Rejected caller-supplied deadline. - write_timeout: Duration, - /// Maximum reviewed deadline accepted by this boundary. - maximum_timeout: Duration, - }, - /// The monotonic total write deadline elapsed before the complete request was written. - WriteDeadlineExceeded { - /// Number of request bytes written before the deadline elapsed. - bytes_written: usize, - }, - /// Applying the remaining operating-system write timeout failed. - WriteTimeoutConfigurationFailed { - /// Number of request bytes already written before configuration failed. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A bounded socket write reported timeout or would-block before completion. - WriteTimedOut { - /// Number of request bytes written before the timed-out operation. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A socket write returned zero bytes before the request was complete. - WriteZero { - /// Number of request bytes written before the zero-length write. - bytes_written: usize, - }, - /// A non-recoverable socket write failed before the complete request was emitted. - WriteFailed { - /// Number of request bytes written before the failure. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// Clearing the operation-local socket write timeout failed after all request bytes were sent. - WriteTimeoutCleanupFailed { - /// Number of request bytes already written before cleanup failed. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, -} - -impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidWriteTimeout { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound", - ), - Self::WriteDeadlineExceeded { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write exceeded its monotonic deadline", - ), - Self::WriteTimeoutConfigurationFailed { .. } => formatter.write_str( - "failed to configure the bounded WebDriver BiDi WebSocket opening write timeout", - ), - Self::WriteTimedOut { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write timed out before the request was complete", - ), - Self::WriteZero { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write returned zero before the request was complete", - ), - Self::WriteFailed { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write failed before the request was complete", - ), - Self::WriteTimeoutCleanupFailed { .. } => formatter.write_str( - "failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff", - ), - } - } -} - -impl Error for WebDriverBiDiWebSocketOpeningWriteError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::WriteTimeoutConfigurationFailed { source, .. } - | Self::WriteTimedOut { source, .. } - | Self::WriteFailed { source, .. } - | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), - Self::InvalidWriteTimeout { .. } - | Self::WriteDeadlineExceeded { .. } - | Self::WriteZero { .. } => None, - } - } -} - -trait OpeningRequestWriter { - fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; - fn clear_write_timeout(&self) -> io::Result<()>; - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; -} - -impl OpeningRequestWriter for TcpStream { - fn set_write_timeout(&self, timeout: Duration) -> io::Result<()> { - TcpStream::set_write_timeout(self, Some(timeout)) - } - - fn clear_write_timeout(&self) -> io::Result<()> { - TcpStream::set_write_timeout(self, None) - } - - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { - self.write(bytes) - } -} - -fn write_request_with_clock( - writer: &mut dyn OpeningRequestWriter, - request: &[u8], - write_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result { - let deadline = now() + write_timeout; - let mut bytes_written = 0; - - while bytes_written < request.len() { - let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, - ); - } - writer.set_write_timeout(remaining).map_err(|source| { - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written, - source, - } - })?; - - match writer.write_request_bytes(&request[bytes_written..]) { - Ok(0) => { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); - } - Ok(count) => { - bytes_written += count; - if deadline.saturating_duration_since(now()).is_zero() { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written, - }, - ); - } - } - Err(source) => { - if source.kind() == io::ErrorKind::Interrupted { - continue; - } - if matches!( - source.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written, - source, - }); - } - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written, - source, - }); - } - } - } - - writer.clear_write_timeout().map_err(|source| { - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written, - source, - } - })?; - - Ok(bytes_written) -} - -#[cfg(test)] -#[allow(clippy::expect_used)] -mod opening_write_tests { - use super::*; - use std::{ - collections::VecDeque, - net::{Shutdown, TcpListener}, - thread, - }; - - use originweave_core::WebDriverBiDiWebSocketEndpoint; - - #[derive(Debug)] - enum WriteAction { - Count(usize), - Error(io::ErrorKind), - } - - #[derive(Debug)] - struct FakeWriter { - timeout_error: Option, - clear_timeout_error: Option, - actions: VecDeque, - } - - impl FakeWriter { - fn new(actions: impl IntoIterator) -> Self { - Self { - timeout_error: None, - clear_timeout_error: None, - actions: actions.into_iter().collect(), - } - } - } - - impl OpeningRequestWriter for FakeWriter { - fn set_write_timeout(&self, _timeout: Duration) -> io::Result<()> { - if let Some(kind) = self.timeout_error { - return Err(io::Error::from(kind)); - } - Ok(()) - } - - fn clear_write_timeout(&self) -> io::Result<()> { - if let Some(kind) = self.clear_timeout_error { - return Err(io::Error::from(kind)); - } - Ok(()) - } - - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { - let action = self - .actions - .pop_front() - .unwrap_or(WriteAction::Count(bytes.len())); - match action { - WriteAction::Count(count) => Ok(count.min(bytes.len())), - WriteAction::Error(kind) => Err(io::Error::from(kind)), - } - } - } - - impl FrameWriter for FakeWriter { - fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { - let error = if timeout.is_some() { - self.timeout_error - } else { - self.clear_timeout_error - }; - error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) - } - - fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { - self.write_request_bytes(bytes) - } - } - - #[derive(Clone, Debug)] - enum ReadAction { - Byte(u8), - Count(usize), - End, - Error(io::ErrorKind), - } - - #[derive(Debug)] - struct FakeReader { - actions: VecDeque, - mode_error: Option, - cleanup_error: Option, - } - - impl FakeReader { - fn new(actions: impl IntoIterator) -> Self { - Self { - actions: actions.into_iter().collect(), - mode_error: None, - cleanup_error: None, - } - } - } - - impl OpeningResponseReader for FakeReader { - fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { - let error = if nonblocking { - self.mode_error - } else { - self.cleanup_error - }; - error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) - } - - fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { - match self.actions.pop_front().unwrap_or(ReadAction::End) { - ReadAction::Byte(byte) => { - bytes[0] = byte; - Ok(1) - } - ReadAction::Count(count) => Ok(count), - ReadAction::End => Ok(0), - ReadAction::Error(kind) => Err(io::Error::from(kind)), - } - } - } - - fn client_key() -> WebDriverBiDiWebSocketClientKey { - WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ==") - .expect("test client key must be valid") - } - - fn valid_response() -> Vec { - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec() - } - - fn byte_actions(bytes: &[u8]) -> Vec { - bytes.iter().copied().map(ReadAction::Byte).collect() - } - - fn is_malformed_response(response: &[u8], key: &WebDriverBiDiWebSocketClientKey) -> bool { - matches!( - parse_opening_response(response, key), - Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { .. }) - ) - } - - fn read_with_fake( - reader: &mut FakeReader, - now_values: impl IntoIterator, - ) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { - let key = client_key(); - let fallback = Instant::now(); - let mut now_values = now_values.into_iter(); - let mut now = || now_values.next().unwrap_or(fallback); - read_opening_response_with_clock(reader, &key, Duration::from_secs(1), &mut now) - } - - fn read_frame_with_fake( - reader: &mut FakeReader, - now_values: impl IntoIterator, - ) -> Result { - let fallback = Instant::now(); - let mut now_values = now_values.into_iter(); - let mut now = || now_values.next().unwrap_or(fallback); - read_frame_with_clock(reader, Duration::from_secs(1), &mut now) - } - - #[test] - fn parser_accepts_case_insensitive_upgrade_tokens_and_rejects_malformed_headers() { - let key = client_key(); - let response = b"HTTP/1.1 101 Switching Protocols\r\nUpGrAdE: WebSocket\r\nConnection: keep-alive, Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nX-Test: retained\r\n\r\n"; - let parsed = parse_opening_response(response, &key).expect("valid response"); - assert_eq!(parsed.status_code, 101); - assert_eq!(parsed.byte_count, response.len()); - assert!(!is_malformed_response(response, &key)); - let same_length_mismatch = String::from_utf8(response.to_vec()) - .expect("valid response fixture") - .replace( - "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", - "s3pPLMBiTxaQ9kYGzzhZRbK+xOoX", - ); - assert!(parse_opening_response(same_length_mismatch.as_bytes(), &key).is_err()); - - let malformed_responses = [ - b"HTTP/1.1 101".to_vec(), - vec![0xff, b'\r', b'\n', b'\r', b'\n'], - b"HTTP/1.1 101\0 Switching Protocols\r\n\r\n".to_vec(), - b"HTTP/1.1 200 OK\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nBad Header: value\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\n: value\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: web\x01socket\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nUpgrade: websocket\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nConnection: Upgrade\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nSec-WebSocket-Accept: one\r\nSec-WebSocket-Accept: two\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: h2c\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: keep-alive\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n".to_vec(), - ]; - for response in malformed_responses { - assert!(is_malformed_response(&response, &key)); - } - } - - #[test] - fn bounded_response_reader_covers_deadlines_size_io_and_cleanup() { - let start = Instant::now(); - - let mut valid_reader = FakeReader::new(byte_actions(&valid_response())); - let valid = read_with_fake(&mut valid_reader, [start]); - assert!(valid.is_ok()); - - let mut malformed_reader = FakeReader::new(byte_actions(b"HTTP/1.1 200 OK\r\n\r\n")); - assert!(read_with_fake(&mut malformed_reader, [start]).is_err()); - - let mut interrupted_reader = FakeReader::new( - std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) - .chain(byte_actions(&valid_response())), - ); - assert!(read_with_fake(&mut interrupted_reader, [start]).is_ok()); - - let mut mode_error_reader = FakeReader::new([]); - mode_error_reader.mode_error = Some(io::ErrorKind::InvalidInput); - assert!(read_with_fake(&mut mode_error_reader, [start]).is_err()); - - let mut ended_reader = FakeReader::new([ReadAction::End]); - assert!(read_with_fake(&mut ended_reader, [start]).is_err()); - - let mut count_reader = FakeReader::new([ReadAction::Count(2)]); - assert!(read_with_fake(&mut count_reader, [start]).is_err()); - - let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); - assert!(read_with_fake(&mut failed_reader, [start]).is_err()); - - let mut retrying_reader = FakeReader::new( - std::iter::once(ReadAction::Error(io::ErrorKind::WouldBlock)) - .chain(byte_actions(&valid_response())), - ); - assert!(read_with_fake(&mut retrying_reader, [start]).is_ok()); - - let mut timed_out_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::TimedOut)]); - assert!( - read_with_fake( - &mut timed_out_reader, - [start, start, start + Duration::from_secs(1)] - ) - .is_err() - ); - - let mut deadline_reader = FakeReader::new([ReadAction::End]); - assert!( - read_with_fake( - &mut deadline_reader, - [start, start + Duration::from_secs(1)] - ) - .is_err() - ); - - let mut late_response_reader = FakeReader::new(byte_actions(&valid_response())); - let mut late_response_times = vec![start; valid_response().len() + 1]; - late_response_times.push(start + Duration::from_secs(1)); - assert!(read_with_fake(&mut late_response_reader, late_response_times).is_err()); - - let mut cleanup_reader = FakeReader::new(byte_actions(&valid_response())); - cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); - assert!(read_with_fake(&mut cleanup_reader, [start]).is_err()); - - let mut too_large_reader = FakeReader::new(std::iter::repeat_n( - ReadAction::Byte(b'a'), - MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, - )); - assert!(read_with_fake(&mut too_large_reader, [start]).is_err()); - } - - #[test] - fn response_errors_have_deterministic_messages_and_sources() { - let source = io::Error::from(io::ErrorKind::InvalidInput); - let errors = [ - WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { - response_timeout: Duration::ZERO, - maximum_timeout: MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { - bytes_read: 1, - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { - bytes_read: 1, - maximum_bytes: 1, - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { - bytes_read: 1, - }, - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "test" }, - WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch, - WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { source }, - ]; - for (error, has_source) in errors.iter().zip([ - false, false, false, true, true, true, false, false, false, true, - ]) { - assert!(!error.to_string().is_empty()); - assert_eq!(error.source().is_some(), has_source); - } - } - - #[test] - fn bounded_writer_completes_partial_and_interrupted_writes() { - let mut writer = FakeWriter::new([ - WriteAction::Count(2), - WriteAction::Error(io::ErrorKind::Interrupted), - WriteAction::Count(3), - ]); - let start = Instant::now(); - let mut times = VecDeque::from([start, start, start, start]); - let mut now = || times.pop_front().unwrap_or(start); - let result = - write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); - let is_five = |candidate: Result| { - matches!(candidate, Ok(5)) - }; - assert!(is_five(result)); - assert!(!is_five(Ok(4))); - } - - fn join_loopback_server(server: thread::JoinHandle>) -> bool { - match server.join() { - Ok(result) => { - result.expect("loopback server must accept the client"); - false - } - Err(_) => true, - } - } - - #[test] - fn bounded_writer_clears_real_socket_timeout_before_success() { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); - let address = listener - .local_addr() - .expect("test listener address must be available"); - let server = thread::spawn(move || listener.accept().map(|_| ())); - let mut stream = TcpStream::connect(address).expect("test client must connect"); - let start = Instant::now(); - let mut now = || start; - - let request_byte_count = - write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) - .expect("the opening request must be written"); - - assert_eq!(request_byte_count, 7); - assert_eq!( - stream - .write_timeout() - .expect("the socket timeout must be inspectable"), - None - ); - assert!(!join_loopback_server(server)); - } - - #[test] - fn panicked_loopback_server_is_reported() { - let server = thread::spawn(|| -> io::Result<()> { - std::panic::resume_unwind(Box::new("intentional test-only server panic")); - }); - - assert!(join_loopback_server(server)); - } - - #[test] - fn bounded_writer_rejects_cleanup_failure_without_success_handoff() { - let mut writer = FakeWriter::new([WriteAction::Count(1)]); - writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); - let start = Instant::now(); - let mut now = || start; - - let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_cleanup_failure = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - .. - } - ) - ) - }; - assert!(is_cleanup_failure(result)); - assert!(!is_cleanup_failure(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } - ))); - } - - #[test] - fn bounded_writer_rejects_completion_observed_after_total_deadline() { - let mut writer = FakeWriter::new([WriteAction::Count(1)]); - let start = Instant::now(); - let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); - let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); - let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_deadline_after_one = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 1 - } - ) - ) - }; - assert!(is_deadline_after_one(result)); - assert!(!is_deadline_after_one(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } - ))); - } - - #[test] - fn bounded_writer_classifies_deadline_timeout_zero_and_io_failures() { - let start = Instant::now(); - - let mut deadline_writer = FakeWriter::new([]); - let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); - let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); - let deadline = write_request_with_clock( - &mut deadline_writer, - b"x", - Duration::from_secs(1), - &mut deadline_now, - ); - let is_deadline_before_write = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 0 - } - ) - ) - }; - assert!(is_deadline_before_write(deadline)); - assert!(!is_deadline_before_write(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - - let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); - let mut zero_now = || start; - let zero = write_request_with_clock( - &mut zero_writer, - b"x", - Duration::from_secs(1), - &mut zero_now, - ); - let is_zero_write = |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) - ) - }; - assert!(is_zero_write(zero)); - assert!(!is_zero_write(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } - ))); - - for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { - let mut writer = FakeWriter::new([WriteAction::Error(kind)]); - let mut now = || start; - let timed_out = - write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_timed_out = - |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written: 0, - .. - }) - ) - }; - assert!(is_timed_out(timed_out)); - assert!(!is_timed_out(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - source: io::Error::from(kind), - } - ))); - } - - let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - let mut failed_now = || start; - let failed = write_request_with_clock( - &mut failed_writer, - b"x", - Duration::from_secs(1), - &mut failed_now, - ); - let is_failed = |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - .. - }) - ) - }; - assert!(is_failed(failed)); - assert!(!is_failed(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - - let mut configuration_writer = FakeWriter::new([]); - configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); - let mut configuration_now = || start; - let configuration = write_request_with_clock( - &mut configuration_writer, - b"x", - Duration::from_secs(1), - &mut configuration_now, - ); - let is_configuration_failure = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - .. - } - ) - ) - }; - assert!(is_configuration_failure(configuration)); - assert!(!is_configuration_failure(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - } - - #[test] - fn opening_write_errors_have_deterministic_messages_and_sources() { - let invalid = WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { - write_timeout: Duration::ZERO, - maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, - }; - let deadline = - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; - let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }; - let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }; - let zero = WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 }; - let failed = WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }; - let cleanup = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }; - - assert!(!invalid.to_string().is_empty()); - assert!(!deadline.to_string().is_empty()); - assert!(!configure.to_string().is_empty()); - assert!(!timed_out.to_string().is_empty()); - assert!(!zero.to_string().is_empty()); - assert!(!failed.to_string().is_empty()); - assert!(!cleanup.to_string().is_empty()); - assert!(invalid.source().is_none()); - assert!(deadline.source().is_none()); - assert!(configure.source().is_some()); - assert!(timed_out.source().is_some()); - assert!(zero.source().is_none()); - assert!(failed.source().is_some()); - assert!(cleanup.source().is_some()); - } - - #[test] - fn frame_codec_reader_writer_and_errors_are_fully_bounded() { - let masking_key = WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]); - assert_eq!(masking_key.as_bytes(), &[0x37, 0xfa, 0x21, 0x3d]); - for payload in [vec![b'x'; 125], vec![b'x'; 126], vec![b'x'; 65_536]] { - let frame = serialize_text_frame(&payload, masking_key); - assert_eq!(frame[0], 0x81); - assert_ne!(frame[1] & 0x80, 0); - let mask_offset = match payload.len() { - 0..=125 => 2, - 126..=65_535 => 4, - _ => 10, - }; - assert_eq!(&frame[mask_offset..mask_offset + 4], masking_key.as_bytes()); - } - - let start = Instant::now(); - let valid = [0x81, 0x01, b'x']; - let mut valid_reader = FakeReader::new(byte_actions(&valid)); - let valid_frame = read_frame_with_fake(&mut valid_reader, [start]).expect("valid frame"); - assert!(valid_frame.fin()); - assert_eq!(valid_frame.opcode(), 0x1); - assert_eq!(valid_frame.payload(), b"x"); - - let mut ping_reader = FakeReader::new([ReadAction::Byte(0x89), ReadAction::Byte(0)]); - let ping = read_frame_with_fake(&mut ping_reader, [start]).expect("ping frame"); - assert!(ping.fin()); - assert_eq!(ping.opcode(), 0x9); - - let mut continuation_reader = - FakeReader::new([ReadAction::Byte(0x00), ReadAction::Byte(0)]); - let continuation = - read_frame_with_fake(&mut continuation_reader, [start]).expect("continuation frame"); - assert!(!continuation.fin()); - assert_eq!(continuation.opcode(), 0); - - let mut extended_16 = FakeReader::new( - byte_actions(&[0x81, 126, 0, 126]) - .into_iter() - .chain([ReadAction::Count(126)]), - ); - assert_eq!( - read_frame_with_fake(&mut extended_16, [start]) - .expect("extended frame") - .payload() - .len(), - 126 - ); - let mut extended_64 = FakeReader::new( - byte_actions(&[0x81, 127, 0, 0, 0, 0, 0, 1, 0, 0]) - .into_iter() - .chain([ReadAction::Count(65_536)]), - ); - assert_eq!( - read_frame_with_fake(&mut extended_64, [start]) - .expect("large extended frame") - .payload() - .len(), - 65_536 - ); - let mut extended_16_error = FakeReader::new([ - ReadAction::Byte(0x81), - ReadAction::Byte(126), - ReadAction::Error(io::ErrorKind::BrokenPipe), - ]); - assert!(read_frame_with_fake(&mut extended_16_error, [start]).is_err()); - let mut extended_64_error = FakeReader::new([ - ReadAction::Byte(0x81), - ReadAction::Byte(127), - ReadAction::Error(io::ErrorKind::BrokenPipe), - ]); - assert!(read_frame_with_fake(&mut extended_64_error, [start]).is_err()); - - let mut oversized_header = vec![0x81, 127]; - oversized_header - .extend_from_slice(&((MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64) + 1).to_be_bytes()); - let mut malformed_readers = vec![ - vec![0xc1, 0], - vec![0x09, 0], - vec![0x83, 0], - vec![0x81, 0x80], - vec![0x81, 126, 0, 1], - vec![0x81, 127, 0x80, 0, 0, 0, 0, 0, 0, 0], - vec![0x81, 127, 0, 0, 0, 0, 0, 0, 0xff, 0xff], - vec![0x89, 126, 0, 126], - oversized_header, - ]; - for bytes in malformed_readers.drain(..) { - let mut reader = FakeReader::new(byte_actions(&bytes)); - assert!(read_frame_with_fake(&mut reader, [start]).is_err()); - } - let mut count_reader = FakeReader::new([ReadAction::Count(3)]); - assert!(read_frame_with_fake(&mut count_reader, [start]).is_err()); - let mut ended_reader = FakeReader::new([ReadAction::Byte(0x81), ReadAction::End]); - assert!(read_frame_with_fake(&mut ended_reader, [start]).is_err()); - let mut interrupted_reader = FakeReader::new( - std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) - .chain(byte_actions(&valid)), - ); - assert!(read_frame_with_fake(&mut interrupted_reader, [start]).is_ok()); - for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { - let mut retrying_reader = FakeReader::new( - std::iter::once(ReadAction::Error(kind)).chain(byte_actions(&valid)), - ); - assert!(read_frame_with_fake(&mut retrying_reader, [start]).is_ok()); - } - let mut payload_error_reader = FakeReader::new([ - ReadAction::Byte(0x81), - ReadAction::Byte(1), - ReadAction::Error(io::ErrorKind::BrokenPipe), - ]); - assert!(read_frame_with_fake(&mut payload_error_reader, [start]).is_err()); - let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); - assert!(read_frame_with_fake(&mut failed_reader, [start]).is_err()); - let mut mode_reader = FakeReader::new([]); - mode_reader.mode_error = Some(io::ErrorKind::InvalidInput); - assert!(read_frame_with_fake(&mut mode_reader, [start]).is_err()); - let mut timeout_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::WouldBlock)]); - assert!( - read_frame_with_fake( - &mut timeout_reader, - [start, start, start + Duration::from_secs(1)] - ) - .is_err() - ); - let mut deadline_reader = FakeReader::new([]); - assert!( - read_frame_with_fake( - &mut deadline_reader, - [start, start + Duration::from_secs(1)] - ) - .is_err() - ); - let mut cleanup_reader = FakeReader::new(byte_actions(&valid)); - cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); - assert!(read_frame_with_fake(&mut cleanup_reader, [start]).is_err()); - - let mut writer = FakeWriter::new([ - WriteAction::Count(1), - WriteAction::Error(io::ErrorKind::Interrupted), - WriteAction::Count(99), - ]); - let mut now = || start; - assert_eq!( - write_frame_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now) - .expect("frame write"), - 5 - ); - let mut empty_writer = FakeWriter::new([]); - let mut empty_now = || start; - assert_eq!( - write_frame_with_clock( - &mut empty_writer, - b"", - Duration::from_secs(1), - &mut empty_now - ) - .expect("empty frame write"), - 0 - ); - let mut deadline_writer = FakeWriter::new([]); - let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); - let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); - assert!( - write_frame_with_clock( - &mut deadline_writer, - b"x", - Duration::from_secs(1), - &mut deadline_now - ) - .is_err() - ); - let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); - let mut zero_now = || start; - assert!( - write_frame_with_clock( - &mut zero_writer, - b"x", - Duration::from_secs(1), - &mut zero_now - ) - .is_err() - ); - for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { - let mut writer = FakeWriter::new([WriteAction::Error(kind)]); - let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); - let mut now = || times.pop_front().unwrap_or(start); - assert!( - write_frame_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now) - .is_err() - ); - } - let mut retrying_writer = FakeWriter::new([ - WriteAction::Error(io::ErrorKind::WouldBlock), - WriteAction::Count(1), - ]); - let mut retrying_now = || start; - assert_eq!( - write_frame_with_clock( - &mut retrying_writer, - b"x", - Duration::from_secs(1), - &mut retrying_now - ) - .expect("retrying frame write"), - 1 - ); - let mut interrupted_writer = FakeWriter::new([ - WriteAction::Error(io::ErrorKind::Interrupted), - WriteAction::Count(1), - ]); - let mut interrupted_now = || start; - assert_eq!( - write_frame_with_clock( - &mut interrupted_writer, - b"x", - Duration::from_secs(1), - &mut interrupted_now - ) - .expect("interrupted frame write"), - 1 - ); - let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - let mut failed_now = || start; - assert!( - write_frame_with_clock( - &mut failed_writer, - b"x", - Duration::from_secs(1), - &mut failed_now - ) - .is_err() - ); - let mut configuration_writer = FakeWriter::new([]); - configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); - let mut configuration_now = || start; - assert!( - write_frame_with_clock( - &mut configuration_writer, - b"x", - Duration::from_secs(1), - &mut configuration_now - ) - .is_err() - ); - let mut cleanup_writer = FakeWriter::new([WriteAction::Count(1)]); - cleanup_writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); - let mut cleanup_now = || start; - assert!( - write_frame_with_clock( - &mut cleanup_writer, - b"x", - Duration::from_secs(1), - &mut cleanup_now - ) - .is_err() - ); - - for timeout in [ - Duration::ZERO, - MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1), - ] { - assert!(validate_frame_timeout(timeout).is_err()); - } - let errors = [ - WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { - frame_timeout: Duration::ZERO, - maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, - }, - WebDriverBiDiWebSocketFrameError::FrameTooLarge { - payload_bytes: 2, - maximum_bytes: 1, - }, - WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { - source: io::Error::from(io::ErrorKind::InvalidInput), - }, - WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }, - WebDriverBiDiWebSocketFrameError::FrameReadFailed { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }, - WebDriverBiDiWebSocketFrameError::FrameEnded { bytes_read: 1 }, - WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "test" }, - WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }, - WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }, - WebDriverBiDiWebSocketFrameError::FrameWriteFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }, - WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written: 1 }, - WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { - source: io::Error::from(io::ErrorKind::InvalidInput), - }, - ]; - for (error, has_source) in errors.iter().zip([ - false, false, true, true, true, false, false, true, true, true, false, true, - ]) { - assert!(!error.to_string().is_empty()); - assert_eq!(error.source().is_some(), has_source); - } - } - - #[test] - fn established_frame_write_discards_locally_revoked_streams() { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); - let address = listener - .local_addr() - .expect("test listener address must be available"); - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("test server must accept"); - stream - .write_all(&valid_response()) - .expect("test server must write response"); - }); - - let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( - "ws://{address}/session/01234567-89ab-cdef-0123-456789abcdef" - )) - .expect("test endpoint must be valid"); - let correlated = endpoint - .correlate_session_id("01234567-89ab-cdef-0123-456789abcdef") - .expect("test session must correlate"); - let target = correlated - .into_explicit_connect_target() - .expect("test target must be explicit"); - let connection = - crate::WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) - .expect("test connection plan must be valid") - .connect() - .expect("test connection must succeed"); - let sent = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) - .expect("test handshake plan must be valid") - .write_opening_request(Duration::from_secs(1)) - .expect("test opening request must be written"); - let established = sent - .read_opening_response(Duration::from_secs(1)) - .expect("test opening response must be valid"); - let _ = established.stream.shutdown(Shutdown::Both); - assert!( - established - .write_text_frame( - "x", - WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]), - Duration::from_secs(1), - ) - .is_err() - ); - assert!(server.join().is_ok()); - } -} diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake/transport_impl.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake/transport_impl.rs new file mode 100644 index 000000000..4e42217f9 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake/transport_impl.rs @@ -0,0 +1,2474 @@ +use std::{ + error::Error, + fmt, + io::{self, Read, Write}, + net::TcpStream, + thread, + time::{Duration, Instant}, +}; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use originweave_core::VerifiedWebDriverBiDiSocketPeer; +use sha1::{Digest, Sha1}; + +use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; + +const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; +const RFC6455_WEBSOCKET_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const MAX_WEBSOCKET_OPENING_RESPONSE_BYTES: usize = 16 * 1024; +const MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES: usize = 1024 * 1024; + +/// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. +/// +/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. The request is +/// already bounded before this budget is applied. Callers may choose any smaller nonzero deadline. +pub const MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Maximum wall-clock budget accepted for reading one bounded WebSocket opening response. +/// +/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. Callers may +/// choose any smaller nonzero deadline. +pub const MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Maximum bytes admitted while reading one WebSocket HTTP opening response. +/// +/// The response is consumed only through its terminating `CRLF CRLF`; WebSocket frames are not +/// read or interpreted by this boundary. +pub const MAX_WEBSOCKET_OPENING_RESPONSE_SIZE: usize = MAX_WEBSOCKET_OPENING_RESPONSE_BYTES; + +/// Maximum payload bytes admitted for one WebSocket frame. +pub const MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE: usize = MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES; + +/// Maximum wall-clock budget accepted for one bounded WebSocket frame I/O operation. +pub const MAX_WEBSOCKET_FRAME_TIMEOUT: Duration = Duration::from_secs(5); + +fn is_base64_data_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') +} + +fn is_canonical_16_byte_base64(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == WEBSOCKET_CLIENT_KEY_LENGTH + && bytes[..22].iter().copied().all(is_base64_data_byte) + && matches!(bytes[21], b'A' | b'Q' | b'g' | b'w') + && bytes[22] == b'=' + && bytes[23] == b'=' +} + +/// Deterministic failures while preparing one WebDriver BiDi RFC 6455 opening request. +#[derive(Debug, Eq, PartialEq)] +pub enum WebDriverBiDiWebSocketHandshakeError { + /// The supplied client key was not the canonical base64 representation of exactly 16 bytes. + InvalidClientKey, + /// The verified WebDriver BiDi target requires TLS before a WebSocket opening request is sent. + TlsRequired, +} + +impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidClientKey => formatter.write_str( + "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes", + ), + Self::TlsRequired => formatter.write_str( + "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request", + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketHandshakeError {} + +/// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. +/// +/// RFC 6455 requires `Sec-WebSocket-Key` to be a nonce of 16 bytes encoded with base64. This type +/// validates only the canonical wire representation, including zero padding bits. It does not +/// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce +/// for each connection attempt. +#[derive(Debug, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketClientKey(String); + +impl WebDriverBiDiWebSocketClientKey { + /// Admit one canonical base64 client key representing exactly 16 bytes. + pub fn new(value: &str) -> Result { + if !is_canonical_16_byte_base64(value) { + return Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey); + } + Ok(Self(value.to_owned())) + } + + /// Borrow the exact canonical value for `Sec-WebSocket-Key` serialization. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Caller-supplied RFC 6455 mask key for one client-to-server frame. +/// +/// RFC 6455 requires every client frame to carry a fresh, unpredictable four-byte key. This type +/// preserves that requirement at the API boundary without inventing an entropy source; callers must +/// obtain a fresh key from an approved randomness source for every frame. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketMaskKey([u8; 4]); + +impl WebDriverBiDiWebSocketMaskKey { + /// Admit one four-byte caller-supplied frame mask key. + #[must_use] + pub const fn new(value: [u8; 4]) -> Self { + Self(value) + } + + /// Borrow the exact four-byte key used on the wire. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 4] { + &self.0 + } +} + +/// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. +/// +/// The plan consumes the verified TCP connection so the opening request cannot be detached from the +/// socket peer/session evidence that authorized its exact loopback destination. It serializes only +/// the fixed WebSocket version-13 request required for the admitted `/session/` resource +/// and retains the exact client key required to validate a later `Sec-WebSocket-Accept` response. +/// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary +/// before any WebSocket bytes may be written. +/// +/// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` +/// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or +/// Agent-authority grant. +#[derive(Debug)] +pub struct WebDriverBiDiWebSocketHandshakePlan { + connection: WebDriverBiDiTcpConnection, + client_key: WebDriverBiDiWebSocketClientKey, + request: Vec, +} + +impl WebDriverBiDiWebSocketHandshakePlan { + /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. + pub fn new( + connection: WebDriverBiDiTcpConnection, + client_key: WebDriverBiDiWebSocketClientKey, + ) -> Result { + if connection.verified_peer().requires_tls() { + return Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired); + } + + let peer = connection.verified_peer(); + let request = format!( + "GET /session/{} HTTP/1.1\r\nHost: {}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n", + peer.session_id(), + peer.socket_addr(), + client_key.as_str(), + ) + .into_bytes(); + + Ok(Self { + connection, + client_key, + request, + }) + } + + /// Borrow the exact serialized RFC 6455 opening-request bytes. + #[must_use] + pub fn request_bytes(&self) -> &[u8] { + &self.request + } + + /// Borrow the exact client key that a later server-handshake validator must correlate. + #[must_use] + pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { + &self.client_key + } + + /// Borrow the exact peer/session evidence already verified before request construction. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + self.connection.verified_peer() + } + + /// Write the complete bounded opening request on the exact verified stream within one deadline. + /// + /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an + /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the + /// destination, or retries after any other I/O failure. A partial write that cannot finish before + /// the same monotonic deadline is an error and yields no successful handoff. Before success, the + /// operation-local socket write timeout is cleared so the next separately reviewed protocol stage + /// cannot inherit stale timeout authority. Success preserves the live stream, exact transport + /// evidence, and client key for a separately reviewed server handshake validator. It does not + /// read or validate the server response and therefore does not establish WebSocket protocol state + /// or browser/Agent authority. + pub fn write_opening_request( + self, + write_timeout: Duration, + ) -> Result + { + if write_timeout.is_zero() || write_timeout > MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }, + ); + } + + let Self { + connection, + client_key, + request, + } = self; + let (mut stream, transport_evidence) = connection.into_parts(); + let mut now = Instant::now; + let request_byte_count = + write_request_with_clock(&mut stream, &request, write_timeout, &mut now)?; + + Ok(WebDriverBiDiWebSocketOpeningRequestSent { + stream, + transport_evidence, + client_key, + request_byte_count, + write_timeout, + }) + } +} + +/// A live verified stream after the complete client opening request has been written. +/// +/// This state proves only that the exact bounded RFC 6455 client request reached the operating +/// system's verified TCP stream before the configured deadline and that this operation's socket write +/// timeout was cleared before handoff. It deliberately does not claim that the peer returned `101 +/// Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket is established, or +/// that the peer is the expected Chromium/ChromeDriver process. Those remain separate fail-closed +/// boundaries. +pub struct WebDriverBiDiWebSocketOpeningRequestSent { + pub(crate) stream: TcpStream, + transport_evidence: WebDriverBiDiTcpConnectionEvidence, + client_key: WebDriverBiDiWebSocketClientKey, + request_byte_count: usize, + write_timeout: Duration, +} + +impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") + .field("stream_local_addr", &self.stream.local_addr().ok()) + .field("transport_evidence", &self.transport_evidence) + .field( + "client_key", + &"", + ) + .field("request_byte_count", &self.request_byte_count) + .field("write_timeout", &self.write_timeout) + .finish() + } +} + +impl WebDriverBiDiWebSocketOpeningRequestSent { + /// Borrow the exact verified transport evidence retained with this live stream. + #[must_use] + pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { + &self.transport_evidence + } + + /// Borrow the exact client key required to validate the later server accept value. + #[must_use] + pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { + &self.client_key + } + + /// Return the exact number of opening-request bytes written before success was emitted. + #[must_use] + pub const fn request_byte_count(&self) -> usize { + self.request_byte_count + } + + /// Return the total write deadline configured for this opening request. + #[must_use] + pub const fn write_timeout(&self) -> Duration { + self.write_timeout + } + + /// Read and validate the bounded RFC 6455 server opening response on this exact stream. + /// + /// Success proves only an HTTP/1.1 `101 Switching Protocols` response with the required + /// `Upgrade`, `Connection`, and client-key-correlated `Sec-WebSocket-Accept` headers. The + /// response body, WebSocket frames, browser process identity, TLS, and browser/Agent authority + /// remain separate boundaries. + pub fn read_opening_response( + self, + response_timeout: Duration, + ) -> Result + { + if response_timeout.is_zero() || response_timeout > MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { + response_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, + }, + ); + } + + let Self { + mut stream, + transport_evidence, + client_key, + request_byte_count, + write_timeout, + } = self; + let mut now = Instant::now; + let (response_status, response_byte_count) = + read_opening_response_with_clock(&mut stream, &client_key, response_timeout, &mut now)?; + + Ok(WebDriverBiDiWebSocketEstablished { + stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + }) + } +} + +/// A live verified stream after both RFC 6455 opening messages were validated. +/// +/// This state does not implement WebSocket framing or grant browser, page, policy, or Agent +/// authority. It retains the exact transport evidence and client key so later protocol stages can +/// remain correlated with the verified peer and opening handshake. +pub struct WebDriverBiDiWebSocketEstablished { + pub(crate) stream: TcpStream, + transport_evidence: WebDriverBiDiTcpConnectionEvidence, + client_key: WebDriverBiDiWebSocketClientKey, + response_status: u16, + response_byte_count: usize, + response_timeout: Duration, + request_byte_count: usize, + write_timeout: Duration, +} + +impl fmt::Debug for WebDriverBiDiWebSocketEstablished { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketEstablished") + .field("stream_local_addr", &self.stream.local_addr().ok()) + .field("transport_evidence", &self.transport_evidence) + .field( + "client_key", + &"", + ) + .field("response_status", &self.response_status) + .field("response_byte_count", &self.response_byte_count) + .field("response_timeout", &self.response_timeout) + .field("request_byte_count", &self.request_byte_count) + .field("write_timeout", &self.write_timeout) + .finish() + } +} + +impl WebDriverBiDiWebSocketEstablished { + /// Borrow the exact verified transport evidence retained with this live stream. + #[must_use] + pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { + &self.transport_evidence + } + + /// Borrow the exact client key correlated with the validated server accept value. + #[must_use] + pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { + &self.client_key + } + + /// Return the validated HTTP status code, currently always `101` on success. + #[must_use] + pub const fn response_status(&self) -> u16 { + self.response_status + } + + /// Return the number of HTTP opening-response bytes consumed through its header terminator. + #[must_use] + pub const fn response_byte_count(&self) -> usize { + self.response_byte_count + } + + /// Return the total response deadline configured for this opening response. + #[must_use] + pub const fn response_timeout(&self) -> Duration { + self.response_timeout + } + + /// Return the number of request bytes written before the response was read. + #[must_use] + pub const fn request_byte_count(&self) -> usize { + self.request_byte_count + } + + /// Return the total write deadline configured for the preceding opening request. + #[must_use] + pub const fn write_timeout(&self) -> Duration { + self.write_timeout + } + + /// Write one unfragmented, masked UTF-8 text frame on this verified stream. + /// + /// The operation consumes the established state and returns it only after the complete frame + /// is written and the temporary socket timeout is cleared. The caller must provide a fresh, + /// unpredictable masking key for this frame; it is never exposed in evidence or debug output. + /// This method does not translate JSON, create a BiDi session, or grant browser/Agent authority. + pub fn write_text_frame( + self, + text: &str, + masking_key: WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result { + validate_frame_timeout(frame_timeout)?; + if text.len() > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES { + return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: text.len(), + maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES, + }); + } + + let frame = serialize_text_frame(text.as_bytes(), masking_key); + let Self { + mut stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + } = self; + let mut now = Instant::now; + write_frame_with_clock(&mut stream, &frame, frame_timeout, &mut now)?; + Ok(Self { + stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + }) + } + + /// Read one bounded RFC 6455 frame from this verified stream. + /// + /// Server-to-client frames must be unmasked. Data and continuation frames are returned one at + /// a time so a later message layer can enforce fragmentation and JSON semantics; control frames + /// are returned to that layer for protocol handling. Reserved bits/opcodes, oversized payloads, + /// noncanonical lengths, and incomplete reads fail closed. Close frames additionally enforce the + /// RFC 6455 payload shape and UTF-8 reason contract before the frame is returned. No frame grants + /// browser/Agent authority. + pub fn read_frame( + self, + frame_timeout: Duration, + ) -> Result<(Self, WebDriverBiDiWebSocketFrame), WebDriverBiDiWebSocketFrameError> { + validate_frame_timeout(frame_timeout)?; + let Self { + mut stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + } = self; + let mut now = Instant::now; + let frame = read_frame_with_clock(&mut stream, frame_timeout, &mut now)?; + Ok(( + Self { + stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + }, + frame, + )) + } +} + +/// One validated WebSocket frame received from the established peer. +#[derive(Debug, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketFrame { + fin: bool, + opcode: u8, + payload: Vec, +} + +impl WebDriverBiDiWebSocketFrame { + /// Return whether this is the final frame in its message. + #[must_use] + pub const fn fin(&self) -> bool { + self.fin + } + + /// Return the RFC 6455 opcode without interpreting application semantics. + #[must_use] + pub const fn opcode(&self) -> u8 { + self.opcode + } + + /// Borrow the bounded, unmasked application payload. + #[must_use] + pub fn payload(&self) -> &[u8] { + &self.payload + } +} + +fn validate_frame_timeout(frame_timeout: Duration) -> Result<(), WebDriverBiDiWebSocketFrameError> { + if frame_timeout.is_zero() || frame_timeout > MAX_WEBSOCKET_FRAME_TIMEOUT { + return Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }); + } + Ok(()) +} + +/// Fail-closed errors while reading or writing one bounded WebSocket frame. +#[derive(Debug)] +pub enum WebDriverBiDiWebSocketFrameError { + /// The requested frame I/O deadline was zero or above the reviewed resource ceiling. + InvalidFrameTimeout { + /// Rejected caller-supplied deadline. + frame_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The frame payload exceeded the reviewed memory ceiling. + FrameTooLarge { + /// Rejected payload length in bytes. + payload_bytes: usize, + /// Maximum payload length admitted by this boundary. + maximum_bytes: usize, + }, + /// Applying the operation-local nonblocking read mode failed. + FrameReadModeConfigurationFailed { + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket read timed out before the frame was complete. + FrameReadTimedOut { + /// Number of frame bytes consumed before timeout. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A non-recoverable socket read failed before the frame was complete. + FrameReadFailed { + /// Number of frame bytes consumed before failure. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// The peer ended the stream before the frame was complete. + FrameEnded { + /// Number of frame bytes consumed before EOF. + bytes_read: usize, + }, + /// The frame header or RFC 6455 control-frame payload violated the protocol contract. + MalformedFrame { + /// Stable, non-secret reason for rejection. + reason: &'static str, + }, + /// Applying the operation-local write timeout failed. + FrameWriteModeConfigurationFailed { + /// Number of frame bytes already written before configuration failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket write timed out before the frame was complete. + FrameWriteTimedOut { + /// Number of frame bytes written before timeout. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A non-recoverable socket write failed before the frame was complete. + FrameWriteFailed { + /// Number of frame bytes written before failure. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// The stream reported zero progress before the frame was complete. + FrameWriteZero { + /// Number of frame bytes written before zero progress. + bytes_written: usize, + }, + /// Clearing the temporary write timeout failed before handoff. + FrameWriteCleanupFailed { + /// Underlying operating-system error. + source: io::Error, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketFrameError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFrameTimeout { .. } => formatter + .write_str("WebDriver BiDi WebSocket frame timeout is outside the reviewed bound"), + Self::FrameTooLarge { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame payload exceeded its bound") + } + Self::FrameReadModeConfigurationFailed { .. } => { + formatter.write_str("failed to configure bounded WebSocket frame reads") + } + Self::FrameReadTimedOut { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame read timed out") + } + Self::FrameReadFailed { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame read failed") + } + Self::FrameEnded { .. } => { + formatter.write_str("WebDriver BiDi WebSocket peer ended the frame stream") + } + Self::MalformedFrame { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame was malformed") + } + Self::FrameWriteModeConfigurationFailed { .. } => { + formatter.write_str("failed to configure bounded WebSocket frame writes") + } + Self::FrameWriteTimedOut { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write timed out") + } + Self::FrameWriteFailed { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write failed") + } + Self::FrameWriteZero { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write made no progress") + } + Self::FrameWriteCleanupFailed { .. } => { + formatter.write_str("failed to clear the WebDriver BiDi WebSocket frame timeout") + } + } + } +} + +impl Error for WebDriverBiDiWebSocketFrameError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::FrameReadModeConfigurationFailed { source } + | Self::FrameReadTimedOut { source, .. } + | Self::FrameReadFailed { source, .. } + | Self::FrameWriteModeConfigurationFailed { source, .. } + | Self::FrameWriteTimedOut { source, .. } + | Self::FrameWriteFailed { source, .. } + | Self::FrameWriteCleanupFailed { source } => Some(source), + Self::InvalidFrameTimeout { .. } + | Self::FrameTooLarge { .. } + | Self::FrameEnded { .. } + | Self::MalformedFrame { .. } + | Self::FrameWriteZero { .. } => None, + } + } +} + +/// Fail-closed errors while reading one bounded WebDriver BiDi WebSocket opening response. +#[derive(Debug)] +pub enum WebDriverBiDiWebSocketHandshakeResponseError { + /// The requested total response deadline was zero or above the reviewed resource ceiling. + InvalidResponseTimeout { + /// Rejected caller-supplied deadline. + response_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The monotonic total response deadline elapsed before validation completed. + ResponseDeadlineExceeded { + /// Number of response bytes consumed before the deadline elapsed. + bytes_read: usize, + }, + /// The response exceeded the reviewed header-size ceiling before its terminator was found. + ResponseTooLarge { + /// Number of response bytes consumed before rejection. + bytes_read: usize, + /// Maximum response bytes admitted by this boundary. + maximum_bytes: usize, + }, + /// Applying the operation-local nonblocking read mode failed. + ResponseReadModeConfigurationFailed { + /// Number of response bytes consumed before configuration failed. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket read timed out before the opening response was complete. + ResponseReadTimedOut { + /// Number of response bytes consumed before the timed-out operation. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A non-recoverable socket read failed before the opening response was complete. + ResponseReadFailed { + /// Number of response bytes consumed before the failure. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// The peer closed the stream before sending a complete HTTP header block. + ResponseEndedBeforeHeaders { + /// Number of response bytes consumed before the peer closed the stream. + bytes_read: usize, + }, + /// The HTTP response was not a valid, required WebSocket opening response. + MalformedResponse { + /// Stable, non-secret reason for the rejected response shape. + reason: &'static str, + }, + /// The response's `Sec-WebSocket-Accept` did not correlate with the sent client key. + AcceptMismatch, + /// Restoring blocking mode failed after validation. + ReadModeCleanupFailed { + /// Underlying operating-system error. + source: io::Error, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketHandshakeResponseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidResponseTimeout { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response timeout is outside the reviewed bound", + ), + Self::ResponseDeadlineExceeded { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response exceeded its monotonic deadline", + ), + Self::ResponseTooLarge { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response exceeded its bounded header size", + ), + Self::ResponseReadModeConfigurationFailed { .. } => formatter.write_str( + "failed to configure bounded nonblocking WebDriver BiDi WebSocket response reads", + ), + Self::ResponseReadTimedOut { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response timed out before completion", + ), + Self::ResponseReadFailed { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response read failed before completion", + ), + Self::ResponseEndedBeforeHeaders { .. } => formatter.write_str( + "WebDriver BiDi WebSocket peer ended the stream before completing response headers", + ), + Self::MalformedResponse { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response was malformed or missing a required header", + ), + Self::AcceptMismatch => formatter.write_str( + "WebDriver BiDi WebSocket opening response accept value did not match the client key", + ), + Self::ReadModeCleanupFailed { .. } => formatter.write_str( + "failed to restore blocking WebDriver BiDi WebSocket response reads before handoff", + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketHandshakeResponseError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::ResponseReadModeConfigurationFailed { source, .. } + | Self::ResponseReadTimedOut { source, .. } + | Self::ResponseReadFailed { source, .. } + | Self::ReadModeCleanupFailed { source } => Some(source), + Self::InvalidResponseTimeout { .. } + | Self::ResponseDeadlineExceeded { .. } + | Self::ResponseTooLarge { .. } + | Self::ResponseEndedBeforeHeaders { .. } + | Self::MalformedResponse { .. } + | Self::AcceptMismatch => None, + } + } +} + +struct ParsedOpeningResponse { + status_code: u16, + byte_count: usize, +} + +fn expected_accept_value(client_key: &WebDriverBiDiWebSocketClientKey) -> String { + let mut digest = Sha1::new(); + digest.update(client_key.as_str().as_bytes()); + digest.update(RFC6455_WEBSOCKET_GUID); + STANDARD.encode(digest.finalize()) +} + +fn is_http_token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) +} + +fn has_header_token(value: &str, expected: &str) -> bool { + value + .split(',') + .map(str::trim) + .any(|token| token.eq_ignore_ascii_case(expected)) +} + +#[allow(clippy::collapsible_if)] +fn parse_opening_response( + response: &[u8], + client_key: &WebDriverBiDiWebSocketClientKey, +) -> Result { + if !response.ends_with(b"\r\n\r\n") { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response is missing its CRLF header terminator", + }, + ); + } + let response_text = std::str::from_utf8(response).map_err(|_| { + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response headers are not valid UTF-8", + } + })?; + let header_text = &response_text[..response_text.len() - 4]; + let (status_line, header_lines) = header_text + .split_once("\r\n") + .map_or((header_text, ""), |(line, rest)| (line, rest)); + if status_line.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "status line contains a control byte", + }, + ); + } + let status_code = status_line + .strip_prefix("HTTP/1.1 ") + .and_then(|rest| rest.split_whitespace().next()) + .and_then(|value| value.parse::().ok()); + if status_code != Some(101) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "status line is not HTTP/1.1 101", + }, + ); + } + + let mut upgrade_has_websocket = false; + let mut connection_has_upgrade = false; + let mut accept = None; + for line in header_lines.split("\r\n") { + if line.is_empty() + || line + .as_bytes() + .first() + .is_some_and(|byte| matches!(byte, b' ' | b'\t')) + { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header line is empty or folded", + }, + ); + } + let (name, value) = line.split_once(':').ok_or( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header line has no colon", + }, + )?; + if name.is_empty() || !name.bytes().all(is_http_token_byte) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header name is not an HTTP token", + }, + ); + } + let value = value.trim_matches([' ', '\t']); + if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header value contains a control byte", + }, + ); + } + if name.eq_ignore_ascii_case("upgrade") { + upgrade_has_websocket |= has_header_token(value, "websocket"); + } else if name.eq_ignore_ascii_case("connection") { + connection_has_upgrade |= has_header_token(value, "upgrade"); + } else if name.eq_ignore_ascii_case("sec-websocket-accept") { + if accept.is_some() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response repeats the Sec-WebSocket-Accept header", + }, + ); + } + accept = Some(value); + } + } + + if !upgrade_has_websocket { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "Upgrade header does not contain websocket", + }, + ); + } + if !connection_has_upgrade { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "Connection header does not contain Upgrade", + }, + ); + } + let Some(accept) = accept else { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response has no Sec-WebSocket-Accept header", + }, + ); + }; + if accept != expected_accept_value(client_key) { + return Err(WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch); + } + + Ok(ParsedOpeningResponse { + status_code: 101, + byte_count: response.len(), + }) +} + +trait OpeningResponseReader { + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()>; + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result; +} + +impl OpeningResponseReader for TcpStream { + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { + TcpStream::set_nonblocking(self, nonblocking) + } + + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { + self.read(bytes) + } +} + +fn serialize_text_frame(payload: &[u8], masking_key: WebDriverBiDiWebSocketMaskKey) -> Vec { + let mut frame = Vec::with_capacity(payload.len() + 14); + frame.push(0x81); + match payload.len() { + 0..=125 => frame.push(0x80 | payload.len() as u8), + 126..=65_535 => { + frame.push(0x80 | 126); + frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + } + length => { + frame.push(0x80 | 127); + frame.extend_from_slice(&(length as u64).to_be_bytes()); + } + } + frame.extend_from_slice(masking_key.as_bytes()); + frame.extend( + payload.iter().enumerate().map(|(index, byte)| { + byte ^ masking_key.as_bytes()[index % masking_key.as_bytes().len()] + }), + ); + frame +} + +trait FrameWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result; +} + +impl FrameWriter for TcpStream { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + TcpStream::set_write_timeout(self, timeout) + } + + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_frame_with_clock( + writer: &mut dyn FrameWriter, + frame: &[u8], + frame_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + frame_timeout; + let mut bytes_written = 0; + while bytes_written < frame.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written, + source: io::Error::new(io::ErrorKind::TimedOut, "frame write deadline elapsed"), + }); + } + writer + .set_write_timeout(Some(remaining)) + .map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written, + source, + } + })?; + match writer.write_frame_bytes(&frame[bytes_written..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written }); + } + Ok(written) => bytes_written += written, + Err(source) => { + if source.kind() == io::ErrorKind::Interrupted { + continue; + } + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) { + if deadline.saturating_duration_since(now()).is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written, + source, + }); + } + thread::sleep(Duration::from_millis(1)); + continue; + } + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written, + source, + }); + } + } + } + writer + .set_write_timeout(None) + .map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { source })?; + Ok(bytes_written) +} + +fn read_frame_with_clock( + reader: &mut dyn OpeningResponseReader, + frame_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + frame_timeout; + reader.set_nonblocking(true).map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { source } + })?; + let mut bytes_read = 0; + let mut header = [0_u8; 2]; + read_frame_bytes_with_clock(reader, &mut header, &mut bytes_read, deadline, now)?; + let first = header[0]; + let second = header[1]; + if first & 0x70 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "reserved frame bits are not negotiated", + }); + } + let fin = first & 0x80 != 0; + let opcode = first & 0x0f; + match opcode { + 0x0..=0x2 => {} + 0x8..=0xa => { + if !fin { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "control frames must not be fragmented", + }); + } + } + _ => { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame opcode is reserved or unsupported", + }); + } + } + if second & 0x80 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "server-to-client frames must not be masked", + }); + } + let length_code = second & 0x7f; + let payload_length = match length_code { + 0..=125 => u64::from(length_code), + 126 => { + let mut extended = [0_u8; 2]; + read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; + let length = u64::from(u16::from_be_bytes(extended)); + if length < 126 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length encoding is not minimal", + }); + } + length + } + _ => { + let mut extended = [0_u8; 8]; + read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; + if extended[0] & 0x80 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length uses the reserved high bit", + }); + } + let length = u64::from_be_bytes(extended); + if length < 65_536 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length encoding is not minimal", + }); + } + length + } + }; + if payload_length > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64 { + return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: payload_length.min(usize::MAX as u64) as usize, + maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES, + }); + } + if opcode >= 0x8 && payload_length > 125 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "control frame payload exceeds 125 bytes", + }); + } + let payload_length = payload_length as usize; + let mut payload = vec![0_u8; payload_length]; + read_frame_bytes_with_clock(reader, &mut payload, &mut bytes_read, deadline, now)?; + if opcode == 0x8 { + if payload.len() == 1 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "Close frame payload must be empty or begin with a two-byte status code", + }); + } + if payload.len() > 1 && std::str::from_utf8(&payload[2..]).is_err() { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "Close frame reason is not valid UTF-8", + }); + } + } + reader.set_nonblocking(false).map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameReadFailed { bytes_read, source } + })?; + Ok(WebDriverBiDiWebSocketFrame { + fin, + opcode, + payload, + }) +} + +fn read_frame_bytes_with_clock( + reader: &mut dyn OpeningResponseReader, + destination: &mut [u8], + bytes_read: &mut usize, + deadline: Instant, + now: &mut dyn FnMut() -> Instant, +) -> Result<(), WebDriverBiDiWebSocketFrameError> { + let mut offset = 0; + while offset < destination.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: *bytes_read, + source: io::Error::new(io::ErrorKind::TimedOut, "frame read deadline elapsed"), + }); + } + match reader.read_response_bytes(&mut destination[offset..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameEnded { + bytes_read: *bytes_read, + }); + } + Ok(read) if read > destination.len() - offset => { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: *bytes_read, + source: io::Error::new( + io::ErrorKind::InvalidData, + "frame reader returned more bytes than requested", + ), + }); + } + Ok(read) => { + offset += read; + *bytes_read += read; + } + Err(source) if source.kind() == io::ErrorKind::Interrupted => {} + Err(source) + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + if deadline.saturating_duration_since(now()).is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: *bytes_read, + source, + }); + } + thread::sleep(Duration::from_millis(1)); + } + Err(source) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: *bytes_read, + source, + }); + } + } + } + Ok(()) +} + +fn read_opening_response_with_clock( + reader: &mut dyn OpeningResponseReader, + client_key: &WebDriverBiDiWebSocketClientKey, + response_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { + let deadline = now() + response_timeout; + let mut response = Vec::new(); + + reader.set_nonblocking(true).map_err(|source| { + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 0, + source, + } + })?; + + loop { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: response.len(), + }, + ); + } + if response.len() >= MAX_WEBSOCKET_OPENING_RESPONSE_BYTES { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { + bytes_read: response.len(), + maximum_bytes: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + }, + ); + } + let mut byte = [0_u8; 1]; + match reader.read_response_bytes(&mut byte) { + Ok(0) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { + bytes_read: response.len(), + }, + ); + } + Ok(1) => { + response.push(byte[0]); + if response.ends_with(b"\r\n\r\n") { + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: response.len(), + }, + ); + } + let parsed = parse_opening_response(&response, client_key)?; + reader.set_nonblocking(false).map_err(|source| { + WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { + source, + } + })?; + return Ok((parsed.status_code, parsed.byte_count)); + } + } + Ok(_) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: response.len(), + source: io::Error::new( + io::ErrorKind::InvalidData, + "response reader returned more bytes than requested", + ), + }, + ); + } + Err(source) if source.kind() == io::ErrorKind::Interrupted => {} + Err(source) + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { + bytes_read: response.len(), + source, + }, + ); + } + thread::sleep(Duration::from_millis(1)); + } + Err(source) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: response.len(), + source, + }, + ); + } + } + } +} + +/// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. +#[derive(Debug)] +pub enum WebDriverBiDiWebSocketOpeningWriteError { + /// The requested total write deadline was zero or above the reviewed resource ceiling. + InvalidWriteTimeout { + /// Rejected caller-supplied deadline. + write_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The monotonic total write deadline elapsed before the complete request was written. + WriteDeadlineExceeded { + /// Number of request bytes written before the deadline elapsed. + bytes_written: usize, + }, + /// Applying the remaining operating-system write timeout failed. + WriteTimeoutConfigurationFailed { + /// Number of request bytes already written before configuration failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket write reported timeout or would-block before completion. + WriteTimedOut { + /// Number of request bytes written before the timed-out operation. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A socket write returned zero bytes before the request was complete. + WriteZero { + /// Number of request bytes written before the zero-length write. + bytes_written: usize, + }, + /// A non-recoverable socket write failed before the complete request was emitted. + WriteFailed { + /// Number of request bytes written before the failure. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// Clearing the operation-local socket write timeout failed after all request bytes were sent. + WriteTimeoutCleanupFailed { + /// Number of request bytes already written before cleanup failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidWriteTimeout { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound", + ), + Self::WriteDeadlineExceeded { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write exceeded its monotonic deadline", + ), + Self::WriteTimeoutConfigurationFailed { .. } => formatter.write_str( + "failed to configure the bounded WebDriver BiDi WebSocket opening write timeout", + ), + Self::WriteTimedOut { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timed out before the request was complete", + ), + Self::WriteZero { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write returned zero before the request was complete", + ), + Self::WriteFailed { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write failed before the request was complete", + ), + Self::WriteTimeoutCleanupFailed { .. } => formatter.write_str( + "failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff", + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketOpeningWriteError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::WriteTimeoutConfigurationFailed { source, .. } + | Self::WriteTimedOut { source, .. } + | Self::WriteFailed { source, .. } + | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), + Self::InvalidWriteTimeout { .. } + | Self::WriteDeadlineExceeded { .. } + | Self::WriteZero { .. } => None, + } + } +} + +trait OpeningRequestWriter { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; + fn clear_write_timeout(&self) -> io::Result<()>; + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; +} + +impl OpeningRequestWriter for TcpStream { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()> { + TcpStream::set_write_timeout(self, Some(timeout)) + } + + fn clear_write_timeout(&self) -> io::Result<()> { + TcpStream::set_write_timeout(self, None) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_request_with_clock( + writer: &mut dyn OpeningRequestWriter, + request: &[u8], + write_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + write_timeout; + let mut bytes_written = 0; + + while bytes_written < request.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, + ); + } + writer.set_write_timeout(remaining).map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written, + source, + } + })?; + + match writer.write_request_bytes(&request[bytes_written..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); + } + Ok(count) => { + bytes_written += count; + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written, + }, + ); + } + } + Err(source) => { + if source.kind() == io::ErrorKind::Interrupted { + continue; + } + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written, + source, + }); + } + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written, + source, + }); + } + } + } + + writer.clear_write_timeout().map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written, + source, + } + })?; + + Ok(bytes_written) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod opening_write_tests { + use super::*; + use std::{ + collections::VecDeque, + net::{Shutdown, TcpListener}, + thread, + }; + + use originweave_core::WebDriverBiDiWebSocketEndpoint; + + #[derive(Debug)] + enum WriteAction { + Count(usize), + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeWriter { + timeout_error: Option, + clear_timeout_error: Option, + actions: VecDeque, + } + + impl FakeWriter { + fn new(actions: impl IntoIterator) -> Self { + Self { + timeout_error: None, + clear_timeout_error: None, + actions: actions.into_iter().collect(), + } + } + } + + impl OpeningRequestWriter for FakeWriter { + fn set_write_timeout(&self, _timeout: Duration) -> io::Result<()> { + if let Some(kind) = self.timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn clear_write_timeout(&self) -> io::Result<()> { + if let Some(kind) = self.clear_timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + let action = self + .actions + .pop_front() + .unwrap_or(WriteAction::Count(bytes.len())); + match action { + WriteAction::Count(count) => Ok(count.min(bytes.len())), + WriteAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + impl FrameWriter for FakeWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + let error = if timeout.is_some() { + self.timeout_error + } else { + self.clear_timeout_error + }; + error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) + } + + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write_request_bytes(bytes) + } + } + + #[derive(Clone, Debug)] + enum ReadAction { + Byte(u8), + Count(usize), + End, + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeReader { + actions: VecDeque, + mode_error: Option, + cleanup_error: Option, + } + + impl FakeReader { + fn new(actions: impl IntoIterator) -> Self { + Self { + actions: actions.into_iter().collect(), + mode_error: None, + cleanup_error: None, + } + } + } + + impl OpeningResponseReader for FakeReader { + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { + let error = if nonblocking { + self.mode_error + } else { + self.cleanup_error + }; + error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) + } + + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { + match self.actions.pop_front().unwrap_or(ReadAction::End) { + ReadAction::Byte(byte) => { + bytes[0] = byte; + Ok(1) + } + ReadAction::Count(count) => Ok(count), + ReadAction::End => Ok(0), + ReadAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + fn client_key() -> WebDriverBiDiWebSocketClientKey { + WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ==") + .expect("test client key must be valid") + } + + fn valid_response() -> Vec { + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec() + } + + fn byte_actions(bytes: &[u8]) -> Vec { + bytes.iter().copied().map(ReadAction::Byte).collect() + } + + fn is_malformed_response(response: &[u8], key: &WebDriverBiDiWebSocketClientKey) -> bool { + matches!( + parse_opening_response(response, key), + Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { .. }) + ) + } + + fn read_with_fake( + reader: &mut FakeReader, + now_values: impl IntoIterator, + ) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { + let key = client_key(); + let fallback = Instant::now(); + let mut now_values = now_values.into_iter(); + let mut now = || now_values.next().unwrap_or(fallback); + read_opening_response_with_clock(reader, &key, Duration::from_secs(1), &mut now) + } + + fn read_frame_with_fake( + reader: &mut FakeReader, + now_values: impl IntoIterator, + ) -> Result { + let fallback = Instant::now(); + let mut now_values = now_values.into_iter(); + let mut now = || now_values.next().unwrap_or(fallback); + read_frame_with_clock(reader, Duration::from_secs(1), &mut now) + } + + #[test] + fn parser_accepts_case_insensitive_upgrade_tokens_and_rejects_malformed_headers() { + let key = client_key(); + let response = b"HTTP/1.1 101 Switching Protocols\r\nUpGrAdE: WebSocket\r\nConnection: keep-alive, Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nX-Test: retained\r\n\r\n"; + let parsed = parse_opening_response(response, &key).expect("valid response"); + assert_eq!(parsed.status_code, 101); + assert_eq!(parsed.byte_count, response.len()); + assert!(!is_malformed_response(response, &key)); + let same_length_mismatch = String::from_utf8(response.to_vec()) + .expect("valid response fixture") + .replace( + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", + "s3pPLMBiTxaQ9kYGzzhZRbK+xOoX", + ); + assert!(parse_opening_response(same_length_mismatch.as_bytes(), &key).is_err()); + + let malformed_responses = [ + b"HTTP/1.1 101".to_vec(), + vec![0xff, b'\r', b'\n', b'\r', b'\n'], + b"HTTP/1.1 101\0 Switching Protocols\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nBad Header: value\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n: value\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: web\x01socket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nUpgrade: websocket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nConnection: Upgrade\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nSec-WebSocket-Accept: one\r\nSec-WebSocket-Accept: two\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: h2c\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: keep-alive\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n".to_vec(), + ]; + for response in malformed_responses { + assert!(is_malformed_response(&response, &key)); + } + } + + #[test] + fn bounded_response_reader_covers_deadlines_size_io_and_cleanup() { + let start = Instant::now(); + + let mut valid_reader = FakeReader::new(byte_actions(&valid_response())); + let valid = read_with_fake(&mut valid_reader, [start]); + assert!(valid.is_ok()); + + let mut malformed_reader = FakeReader::new(byte_actions(b"HTTP/1.1 200 OK\r\n\r\n")); + assert!(read_with_fake(&mut malformed_reader, [start]).is_err()); + + let mut interrupted_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) + .chain(byte_actions(&valid_response())), + ); + assert!(read_with_fake(&mut interrupted_reader, [start]).is_ok()); + + let mut mode_error_reader = FakeReader::new([]); + mode_error_reader.mode_error = Some(io::ErrorKind::InvalidInput); + assert!(read_with_fake(&mut mode_error_reader, [start]).is_err()); + + let mut ended_reader = FakeReader::new([ReadAction::End]); + assert!(read_with_fake(&mut ended_reader, [start]).is_err()); + + let mut count_reader = FakeReader::new([ReadAction::Count(2)]); + assert!(read_with_fake(&mut count_reader, [start]).is_err()); + + let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); + assert!(read_with_fake(&mut failed_reader, [start]).is_err()); + + let mut retrying_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::WouldBlock)) + .chain(byte_actions(&valid_response())), + ); + assert!(read_with_fake(&mut retrying_reader, [start]).is_ok()); + + let mut timed_out_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::TimedOut)]); + assert!( + read_with_fake( + &mut timed_out_reader, + [start, start, start + Duration::from_secs(1)] + ) + .is_err() + ); + + let mut deadline_reader = FakeReader::new([ReadAction::End]); + assert!( + read_with_fake( + &mut deadline_reader, + [start, start + Duration::from_secs(1)] + ) + .is_err() + ); + + let mut late_response_reader = FakeReader::new(byte_actions(&valid_response())); + let mut late_response_times = vec![start; valid_response().len() + 1]; + late_response_times.push(start + Duration::from_secs(1)); + assert!(read_with_fake(&mut late_response_reader, late_response_times).is_err()); + + let mut cleanup_reader = FakeReader::new(byte_actions(&valid_response())); + cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); + assert!(read_with_fake(&mut cleanup_reader, [start]).is_err()); + + let mut too_large_reader = FakeReader::new(std::iter::repeat_n( + ReadAction::Byte(b'a'), + MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + )); + assert!(read_with_fake(&mut too_large_reader, [start]).is_err()); + } + + #[test] + fn response_errors_have_deterministic_messages_and_sources() { + let source = io::Error::from(io::ErrorKind::InvalidInput); + let errors = [ + WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { + response_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { + bytes_read: 1, + maximum_bytes: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { + bytes_read: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "test" }, + WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch, + WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { source }, + ]; + for (error, has_source) in errors.iter().zip([ + false, false, false, true, true, true, false, false, false, true, + ]) { + assert!(!error.to_string().is_empty()); + assert_eq!(error.source().is_some(), has_source); + } + } + + #[test] + fn bounded_writer_completes_partial_and_interrupted_writes() { + let mut writer = FakeWriter::new([ + WriteAction::Count(2), + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(3), + ]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start, start]); + let mut now = || times.pop_front().unwrap_or(start); + let result = + write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); + let is_five = |candidate: Result| { + matches!(candidate, Ok(5)) + }; + assert!(is_five(result)); + assert!(!is_five(Ok(4))); + } + + fn join_loopback_server(server: thread::JoinHandle>) -> bool { + match server.join() { + Ok(result) => { + result.expect("loopback server must accept the client"); + false + } + Err(_) => true, + } + } + + #[test] + fn bounded_writer_clears_real_socket_timeout_before_success() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let server = thread::spawn(move || listener.accept().map(|_| ())); + let mut stream = TcpStream::connect(address).expect("test client must connect"); + let start = Instant::now(); + let mut now = || start; + + let request_byte_count = + write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) + .expect("the opening request must be written"); + + assert_eq!(request_byte_count, 7); + assert_eq!( + stream + .write_timeout() + .expect("the socket timeout must be inspectable"), + None + ); + assert!(!join_loopback_server(server)); + } + + #[test] + fn panicked_loopback_server_is_reported() { + let server = thread::spawn(|| -> io::Result<()> { + std::panic::resume_unwind(Box::new("intentional test-only server panic")); + }); + + assert!(join_loopback_server(server)); + } + + #[test] + fn bounded_writer_rejects_cleanup_failure_without_success_handoff() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); + let start = Instant::now(); + let mut now = || start; + + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_cleanup_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + .. + } + ) + ) + }; + assert!(is_cleanup_failure(result)); + assert!(!is_cleanup_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); + } + + #[test] + fn bounded_writer_rejects_completion_observed_after_total_deadline() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); + let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_deadline_after_one = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 1 + } + ) + ) + }; + assert!(is_deadline_after_one(result)); + assert!(!is_deadline_after_one(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); + } + + #[test] + fn bounded_writer_classifies_deadline_timeout_zero_and_io_failures() { + let start = Instant::now(); + + let mut deadline_writer = FakeWriter::new([]); + let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); + let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); + let deadline = write_request_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + &mut deadline_now, + ); + let is_deadline_before_write = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 0 + } + ) + ) + }; + assert!(is_deadline_before_write(deadline)); + assert!(!is_deadline_before_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + + let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); + let mut zero_now = || start; + let zero = write_request_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + &mut zero_now, + ); + let is_zero_write = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) + ) + }; + assert!(is_zero_write(zero)); + assert!(!is_zero_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } + ))); + + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut writer = FakeWriter::new([WriteAction::Error(kind)]); + let mut now = || start; + let timed_out = + write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_timed_out = + |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_timed_out(timed_out)); + assert!(!is_timed_out(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + source: io::Error::from(kind), + } + ))); + } + + let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let mut failed_now = || start; + let failed = write_request_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + &mut failed_now, + ); + let is_failed = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_failed(failed)); + assert!(!is_failed(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + + let mut configuration_writer = FakeWriter::new([]); + configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let mut configuration_now = || start; + let configuration = write_request_with_clock( + &mut configuration_writer, + b"x", + Duration::from_secs(1), + &mut configuration_now, + ); + let is_configuration_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + .. + } + ) + ) + }; + assert!(is_configuration_failure(configuration)); + assert!(!is_configuration_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + } + + #[test] + fn opening_write_errors_have_deterministic_messages_and_sources() { + let invalid = WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }; + let deadline = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; + let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }; + let zero = WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 }; + let failed = WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }; + let cleanup = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + + assert!(!invalid.to_string().is_empty()); + assert!(!deadline.to_string().is_empty()); + assert!(!configure.to_string().is_empty()); + assert!(!timed_out.to_string().is_empty()); + assert!(!zero.to_string().is_empty()); + assert!(!failed.to_string().is_empty()); + assert!(!cleanup.to_string().is_empty()); + assert!(invalid.source().is_none()); + assert!(deadline.source().is_none()); + assert!(configure.source().is_some()); + assert!(timed_out.source().is_some()); + assert!(zero.source().is_none()); + assert!(failed.source().is_some()); + assert!(cleanup.source().is_some()); + } + + #[test] + fn frame_codec_reader_writer_and_errors_are_fully_bounded() { + let masking_key = WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]); + assert_eq!(masking_key.as_bytes(), &[0x37, 0xfa, 0x21, 0x3d]); + for payload in [vec![b'x'; 125], vec![b'x'; 126], vec![b'x'; 65_536]] { + let frame = serialize_text_frame(&payload, masking_key); + assert_eq!(frame[0], 0x81); + assert_ne!(frame[1] & 0x80, 0); + let mask_offset = match payload.len() { + 0..=125 => 2, + 126..=65_535 => 4, + _ => 10, + }; + assert_eq!(&frame[mask_offset..mask_offset + 4], masking_key.as_bytes()); + } + + let start = Instant::now(); + let valid = [0x81, 0x01, b'x']; + let mut valid_reader = FakeReader::new(byte_actions(&valid)); + let valid_frame = read_frame_with_fake(&mut valid_reader, [start]).expect("valid frame"); + assert!(valid_frame.fin()); + assert_eq!(valid_frame.opcode(), 0x1); + assert_eq!(valid_frame.payload(), b"x"); + + let mut ping_reader = FakeReader::new([ReadAction::Byte(0x89), ReadAction::Byte(0)]); + let ping = read_frame_with_fake(&mut ping_reader, [start]).expect("ping frame"); + assert!(ping.fin()); + assert_eq!(ping.opcode(), 0x9); + + let mut continuation_reader = + FakeReader::new([ReadAction::Byte(0x00), ReadAction::Byte(0)]); + let continuation = + read_frame_with_fake(&mut continuation_reader, [start]).expect("continuation frame"); + assert!(!continuation.fin()); + assert_eq!(continuation.opcode(), 0); + + let mut extended_16 = FakeReader::new( + byte_actions(&[0x81, 126, 0, 126]) + .into_iter() + .chain([ReadAction::Count(126)]), + ); + assert_eq!( + read_frame_with_fake(&mut extended_16, [start]) + .expect("extended frame") + .payload() + .len(), + 126 + ); + let mut extended_64 = FakeReader::new( + byte_actions(&[0x81, 127, 0, 0, 0, 0, 0, 1, 0, 0]) + .into_iter() + .chain([ReadAction::Count(65_536)]), + ); + assert_eq!( + read_frame_with_fake(&mut extended_64, [start]) + .expect("large extended frame") + .payload() + .len(), + 65_536 + ); + let mut extended_16_error = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(126), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut extended_16_error, [start]).is_err()); + let mut extended_64_error = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(127), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut extended_64_error, [start]).is_err()); + + let mut oversized_header = vec![0x81, 127]; + oversized_header + .extend_from_slice(&((MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64) + 1).to_be_bytes()); + let mut malformed_readers = vec![ + vec![0xc1, 0], + vec![0x09, 0], + vec![0x83, 0], + vec![0x81, 0x80], + vec![0x81, 126, 0, 1], + vec![0x81, 127, 0x80, 0, 0, 0, 0, 0, 0, 0], + vec![0x81, 127, 0, 0, 0, 0, 0, 0, 0xff, 0xff], + vec![0x89, 126, 0, 126], + oversized_header, + ]; + for bytes in malformed_readers.drain(..) { + let mut reader = FakeReader::new(byte_actions(&bytes)); + assert!(read_frame_with_fake(&mut reader, [start]).is_err()); + } + let mut count_reader = FakeReader::new([ReadAction::Count(3)]); + assert!(read_frame_with_fake(&mut count_reader, [start]).is_err()); + let mut ended_reader = FakeReader::new([ReadAction::Byte(0x81), ReadAction::End]); + assert!(read_frame_with_fake(&mut ended_reader, [start]).is_err()); + let mut interrupted_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) + .chain(byte_actions(&valid)), + ); + assert!(read_frame_with_fake(&mut interrupted_reader, [start]).is_ok()); + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut retrying_reader = FakeReader::new( + std::iter::once(ReadAction::Error(kind)).chain(byte_actions(&valid)), + ); + assert!(read_frame_with_fake(&mut retrying_reader, [start]).is_ok()); + } + let mut payload_error_reader = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(1), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut payload_error_reader, [start]).is_err()); + let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); + assert!(read_frame_with_fake(&mut failed_reader, [start]).is_err()); + let mut mode_reader = FakeReader::new([]); + mode_reader.mode_error = Some(io::ErrorKind::InvalidInput); + assert!(read_frame_with_fake(&mut mode_reader, [start]).is_err()); + let mut timeout_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::WouldBlock)]); + assert!( + read_frame_with_fake( + &mut timeout_reader, + [start, start, start + Duration::from_secs(1)] + ) + .is_err() + ); + let mut deadline_reader = FakeReader::new([]); + assert!( + read_frame_with_fake( + &mut deadline_reader, + [start, start + Duration::from_secs(1)] + ) + .is_err() + ); + let mut cleanup_reader = FakeReader::new(byte_actions(&valid)); + cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); + assert!(read_frame_with_fake(&mut cleanup_reader, [start]).is_err()); + + let mut writer = FakeWriter::new([ + WriteAction::Count(1), + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(99), + ]); + let mut now = || start; + assert_eq!( + write_frame_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now) + .expect("frame write"), + 5 + ); + let mut empty_writer = FakeWriter::new([]); + let mut empty_now = || start; + assert_eq!( + write_frame_with_clock( + &mut empty_writer, + b"", + Duration::from_secs(1), + &mut empty_now + ) + .expect("empty frame write"), + 0 + ); + let mut deadline_writer = FakeWriter::new([]); + let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); + let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); + assert!( + write_frame_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + &mut deadline_now + ) + .is_err() + ); + let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); + let mut zero_now = || start; + assert!( + write_frame_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + &mut zero_now + ) + .is_err() + ); + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut writer = FakeWriter::new([WriteAction::Error(kind)]); + let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); + let mut now = || times.pop_front().unwrap_or(start); + assert!( + write_frame_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now) + .is_err() + ); + } + let mut retrying_writer = FakeWriter::new([ + WriteAction::Error(io::ErrorKind::WouldBlock), + WriteAction::Count(1), + ]); + let mut retrying_now = || start; + assert_eq!( + write_frame_with_clock( + &mut retrying_writer, + b"x", + Duration::from_secs(1), + &mut retrying_now + ) + .expect("retrying frame write"), + 1 + ); + let mut interrupted_writer = FakeWriter::new([ + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(1), + ]); + let mut interrupted_now = || start; + assert_eq!( + write_frame_with_clock( + &mut interrupted_writer, + b"x", + Duration::from_secs(1), + &mut interrupted_now + ) + .expect("interrupted frame write"), + 1 + ); + let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let mut failed_now = || start; + assert!( + write_frame_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + &mut failed_now + ) + .is_err() + ); + let mut configuration_writer = FakeWriter::new([]); + configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let mut configuration_now = || start; + assert!( + write_frame_with_clock( + &mut configuration_writer, + b"x", + Duration::from_secs(1), + &mut configuration_now + ) + .is_err() + ); + let mut cleanup_writer = FakeWriter::new([WriteAction::Count(1)]); + cleanup_writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); + let mut cleanup_now = || start; + assert!( + write_frame_with_clock( + &mut cleanup_writer, + b"x", + Duration::from_secs(1), + &mut cleanup_now + ) + .is_err() + ); + + for timeout in [ + Duration::ZERO, + MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1), + ] { + assert!(validate_frame_timeout(timeout).is_err()); + } + let errors = [ + WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: 2, + maximum_bytes: 1, + }, + WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketFrameError::FrameEnded { bytes_read: 1 }, + WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "test" }, + WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written: 1 }, + WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + ]; + for (error, has_source) in errors.iter().zip([ + false, false, true, true, true, false, false, true, true, true, false, true, + ]) { + assert!(!error.to_string().is_empty()); + assert_eq!(error.source().is_some(), has_source); + } + } + + #[test] + fn established_frame_write_discards_locally_revoked_streams() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("test server must accept"); + stream + .write_all(&valid_response()) + .expect("test server must write response"); + }); + + let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://{address}/session/01234567-89ab-cdef-0123-456789abcdef" + )) + .expect("test endpoint must be valid"); + let correlated = endpoint + .correlate_session_id("01234567-89ab-cdef-0123-456789abcdef") + .expect("test session must correlate"); + let target = correlated + .into_explicit_connect_target() + .expect("test target must be explicit"); + let connection = + crate::WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("test connection plan must be valid") + .connect() + .expect("test connection must succeed"); + let sent = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) + .expect("test handshake plan must be valid") + .write_opening_request(Duration::from_secs(1)) + .expect("test opening request must be written"); + let established = sent + .read_opening_response(Duration::from_secs(1)) + .expect("test opening response must be valid"); + let _ = established.stream.shutdown(Shutdown::Both); + assert!( + established + .write_text_frame( + "x", + WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]), + Duration::from_secs(1), + ) + .is_err() + ); + assert!(server.join().is_ok()); + } +} From e07aaf0cc0fbdcdc2d3fb382697106f9e1181f89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:11:13 -0700 Subject: [PATCH 297/570] fix(network): validate public WebSocket close frames --- crates/originweave-network/src/lib.rs | 13 +- .../src/webdriver_bidi_websocket_control.rs | 5 +- .../src/webdriver_bidi_websocket_handshake.rs | 2392 +++++++++++++++- .../transport_impl.rs | 2474 ----------------- .../src/webdriver_bidi_websocket_validated.rs | 211 ++ 5 files changed, 2550 insertions(+), 2545 deletions(-) delete mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_handshake/transport_impl.rs create mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_validated.rs diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index cfcecc864..87d909823 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -20,6 +20,7 @@ mod webdriver_bidi_websocket_control; #[allow(clippy::expect_used)] mod webdriver_bidi_websocket_coverage_tests; mod webdriver_bidi_websocket_handshake; +mod webdriver_bidi_websocket_validated; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, @@ -33,9 +34,11 @@ pub use webdriver_bidi_websocket_handshake::{ MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_FRAME_TIMEOUT, MAX_WEBSOCKET_OPENING_RESPONSE_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrame, - WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakeError, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketHandshakeResponseError, - WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningRequestSent, - WebDriverBiDiWebSocketOpeningWriteError, + WebDriverBiDiWebSocketFrame, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, + WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningWriteError, +}; +pub use webdriver_bidi_websocket_validated::{ + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketOpeningRequestSent, }; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs index aa22579ca..4bd645d66 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -6,8 +6,9 @@ use std::{ }; use crate::{ - MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, - WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketMaskKey, + webdriver_bidi_websocket_handshake::WebDriverBiDiWebSocketEstablished, }; const MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES: usize = 125; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 82ef2b92c..4e42217f9 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -1,83 +1,268 @@ -//! Public WebDriver BiDi WebSocket transport façade. -//! -//! The frame transport implementation remains isolated in a private module. This façade preserves -//! the reviewed public API while enforcing RFC 6455 close-status validity before any received Close -//! frame is handed to a caller. - -use std::{fmt, time::Duration}; +use std::{ + error::Error, + fmt, + io::{self, Read, Write}, + net::TcpStream, + thread, + time::{Duration, Instant}, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; use originweave_core::VerifiedWebDriverBiDiSocketPeer; +use sha1::{Digest, Sha1}; use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; -#[path = "webdriver_bidi_websocket_handshake/transport_impl.rs"] -mod transport_impl; +const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; +const RFC6455_WEBSOCKET_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const MAX_WEBSOCKET_OPENING_RESPONSE_BYTES: usize = 16 * 1024; +const MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES: usize = 1024 * 1024; -pub use transport_impl::{ - MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_FRAME_TIMEOUT, - MAX_WEBSOCKET_OPENING_RESPONSE_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, - MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketFrame, WebDriverBiDiWebSocketFrameError, - WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, - WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningWriteError, -}; +/// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. +/// +/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. The request is +/// already bounded before this budget is applied. Callers may choose any smaller nonzero deadline. +pub const MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT: Duration = Duration::from_secs(5); -/// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. -pub struct WebDriverBiDiWebSocketHandshakePlan(transport_impl::WebDriverBiDiWebSocketHandshakePlan); +/// Maximum wall-clock budget accepted for reading one bounded WebSocket opening response. +/// +/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. Callers may +/// choose any smaller nonzero deadline. +pub const MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Maximum bytes admitted while reading one WebSocket HTTP opening response. +/// +/// The response is consumed only through its terminating `CRLF CRLF`; WebSocket frames are not +/// read or interpreted by this boundary. +pub const MAX_WEBSOCKET_OPENING_RESPONSE_SIZE: usize = MAX_WEBSOCKET_OPENING_RESPONSE_BYTES; + +/// Maximum payload bytes admitted for one WebSocket frame. +pub const MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE: usize = MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES; + +/// Maximum wall-clock budget accepted for one bounded WebSocket frame I/O operation. +pub const MAX_WEBSOCKET_FRAME_TIMEOUT: Duration = Duration::from_secs(5); + +fn is_base64_data_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') +} -impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { +fn is_canonical_16_byte_base64(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == WEBSOCKET_CLIENT_KEY_LENGTH + && bytes[..22].iter().copied().all(is_base64_data_byte) + && matches!(bytes[21], b'A' | b'Q' | b'g' | b'w') + && bytes[22] == b'=' + && bytes[23] == b'=' +} + +/// Deterministic failures while preparing one WebDriver BiDi RFC 6455 opening request. +#[derive(Debug, Eq, PartialEq)] +pub enum WebDriverBiDiWebSocketHandshakeError { + /// The supplied client key was not the canonical base64 representation of exactly 16 bytes. + InvalidClientKey, + /// The verified WebDriver BiDi target requires TLS before a WebSocket opening request is sent. + TlsRequired, +} + +impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(formatter) + match self { + Self::InvalidClientKey => formatter.write_str( + "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes", + ), + Self::TlsRequired => formatter.write_str( + "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request", + ), + } } } +impl Error for WebDriverBiDiWebSocketHandshakeError {} + +/// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. +/// +/// RFC 6455 requires `Sec-WebSocket-Key` to be a nonce of 16 bytes encoded with base64. This type +/// validates only the canonical wire representation, including zero padding bits. It does not +/// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce +/// for each connection attempt. +#[derive(Debug, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketClientKey(String); + +impl WebDriverBiDiWebSocketClientKey { + /// Admit one canonical base64 client key representing exactly 16 bytes. + pub fn new(value: &str) -> Result { + if !is_canonical_16_byte_base64(value) { + return Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey); + } + Ok(Self(value.to_owned())) + } + + /// Borrow the exact canonical value for `Sec-WebSocket-Key` serialization. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Caller-supplied RFC 6455 mask key for one client-to-server frame. +/// +/// RFC 6455 requires every client frame to carry a fresh, unpredictable four-byte key. This type +/// preserves that requirement at the API boundary without inventing an entropy source; callers must +/// obtain a fresh key from an approved randomness source for every frame. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketMaskKey([u8; 4]); + +impl WebDriverBiDiWebSocketMaskKey { + /// Admit one four-byte caller-supplied frame mask key. + #[must_use] + pub const fn new(value: [u8; 4]) -> Self { + Self(value) + } + + /// Borrow the exact four-byte key used on the wire. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 4] { + &self.0 + } +} + +/// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. +/// +/// The plan consumes the verified TCP connection so the opening request cannot be detached from the +/// socket peer/session evidence that authorized its exact loopback destination. It serializes only +/// the fixed WebSocket version-13 request required for the admitted `/session/` resource +/// and retains the exact client key required to validate a later `Sec-WebSocket-Accept` response. +/// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary +/// before any WebSocket bytes may be written. +/// +/// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` +/// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or +/// Agent-authority grant. +#[derive(Debug)] +pub struct WebDriverBiDiWebSocketHandshakePlan { + connection: WebDriverBiDiTcpConnection, + client_key: WebDriverBiDiWebSocketClientKey, + request: Vec, +} + impl WebDriverBiDiWebSocketHandshakePlan { /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. pub fn new( connection: WebDriverBiDiTcpConnection, client_key: WebDriverBiDiWebSocketClientKey, ) -> Result { - transport_impl::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key).map(Self) + if connection.verified_peer().requires_tls() { + return Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired); + } + + let peer = connection.verified_peer(); + let request = format!( + "GET /session/{} HTTP/1.1\r\nHost: {}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n", + peer.session_id(), + peer.socket_addr(), + client_key.as_str(), + ) + .into_bytes(); + + Ok(Self { + connection, + client_key, + request, + }) } /// Borrow the exact serialized RFC 6455 opening-request bytes. #[must_use] pub fn request_bytes(&self) -> &[u8] { - self.0.request_bytes() + &self.request } /// Borrow the exact client key that a later server-handshake validator must correlate. #[must_use] pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { - self.0.client_key() + &self.client_key } /// Borrow the exact peer/session evidence already verified before request construction. #[must_use] pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { - self.0.verified_peer() + self.connection.verified_peer() } /// Write the complete bounded opening request on the exact verified stream within one deadline. + /// + /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an + /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the + /// destination, or retries after any other I/O failure. A partial write that cannot finish before + /// the same monotonic deadline is an error and yields no successful handoff. Before success, the + /// operation-local socket write timeout is cleared so the next separately reviewed protocol stage + /// cannot inherit stale timeout authority. Success preserves the live stream, exact transport + /// evidence, and client key for a separately reviewed server handshake validator. It does not + /// read or validate the server response and therefore does not establish WebSocket protocol state + /// or browser/Agent authority. pub fn write_opening_request( self, write_timeout: Duration, ) -> Result { - self.0 - .write_opening_request(write_timeout) - .map(WebDriverBiDiWebSocketOpeningRequestSent) + if write_timeout.is_zero() || write_timeout > MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }, + ); + } + + let Self { + connection, + client_key, + request, + } = self; + let (mut stream, transport_evidence) = connection.into_parts(); + let mut now = Instant::now; + let request_byte_count = + write_request_with_clock(&mut stream, &request, write_timeout, &mut now)?; + + Ok(WebDriverBiDiWebSocketOpeningRequestSent { + stream, + transport_evidence, + client_key, + request_byte_count, + write_timeout, + }) } } -/// A live verified stream after the complete client WebSocket opening request has been written. -pub struct WebDriverBiDiWebSocketOpeningRequestSent( - transport_impl::WebDriverBiDiWebSocketOpeningRequestSent, -); +/// A live verified stream after the complete client opening request has been written. +/// +/// This state proves only that the exact bounded RFC 6455 client request reached the operating +/// system's verified TCP stream before the configured deadline and that this operation's socket write +/// timeout was cleared before handoff. It deliberately does not claim that the peer returned `101 +/// Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket is established, or +/// that the peer is the expected Chromium/ChromeDriver process. Those remain separate fail-closed +/// boundaries. +pub struct WebDriverBiDiWebSocketOpeningRequestSent { + pub(crate) stream: TcpStream, + transport_evidence: WebDriverBiDiTcpConnectionEvidence, + client_key: WebDriverBiDiWebSocketClientKey, + request_byte_count: usize, + write_timeout: Duration, +} impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(formatter) + formatter + .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") + .field("stream_local_addr", &self.stream.local_addr().ok()) + .field("transport_evidence", &self.transport_evidence) + .field( + "client_key", + &"", + ) + .field("request_byte_count", &self.request_byte_count) + .field("write_timeout", &self.write_timeout) + .finish() } } @@ -85,45 +270,103 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { /// Borrow the exact verified transport evidence retained with this live stream. #[must_use] pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { - self.0.transport_evidence() + &self.transport_evidence } /// Borrow the exact client key required to validate the later server accept value. #[must_use] pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { - self.0.client_key() + &self.client_key } /// Return the exact number of opening-request bytes written before success was emitted. #[must_use] pub const fn request_byte_count(&self) -> usize { - self.0.request_byte_count() + self.request_byte_count } /// Return the total write deadline configured for this opening request. #[must_use] pub const fn write_timeout(&self) -> Duration { - self.0.write_timeout() + self.write_timeout } /// Read and validate the bounded RFC 6455 server opening response on this exact stream. + /// + /// Success proves only an HTTP/1.1 `101 Switching Protocols` response with the required + /// `Upgrade`, `Connection`, and client-key-correlated `Sec-WebSocket-Accept` headers. The + /// response body, WebSocket frames, browser process identity, TLS, and browser/Agent authority + /// remain separate boundaries. pub fn read_opening_response( self, response_timeout: Duration, ) -> Result { - self.0 - .read_opening_response(response_timeout) - .map(WebDriverBiDiWebSocketEstablished) + if response_timeout.is_zero() || response_timeout > MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { + response_timeout, + maximum_timeout: MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, + }, + ); + } + + let Self { + mut stream, + transport_evidence, + client_key, + request_byte_count, + write_timeout, + } = self; + let mut now = Instant::now; + let (response_status, response_byte_count) = + read_opening_response_with_clock(&mut stream, &client_key, response_timeout, &mut now)?; + + Ok(WebDriverBiDiWebSocketEstablished { + stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + }) } } /// A live verified stream after both RFC 6455 opening messages were validated. -pub struct WebDriverBiDiWebSocketEstablished(transport_impl::WebDriverBiDiWebSocketEstablished); +/// +/// This state does not implement WebSocket framing or grant browser, page, policy, or Agent +/// authority. It retains the exact transport evidence and client key so later protocol stages can +/// remain correlated with the verified peer and opening handshake. +pub struct WebDriverBiDiWebSocketEstablished { + pub(crate) stream: TcpStream, + transport_evidence: WebDriverBiDiTcpConnectionEvidence, + client_key: WebDriverBiDiWebSocketClientKey, + response_status: u16, + response_byte_count: usize, + response_timeout: Duration, + request_byte_count: usize, + write_timeout: Duration, +} impl fmt::Debug for WebDriverBiDiWebSocketEstablished { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(formatter) + formatter + .debug_struct("WebDriverBiDiWebSocketEstablished") + .field("stream_local_addr", &self.stream.local_addr().ok()) + .field("transport_evidence", &self.transport_evidence) + .field( + "client_key", + &"", + ) + .field("response_status", &self.response_status) + .field("response_byte_count", &self.response_byte_count) + .field("response_timeout", &self.response_timeout) + .field("request_byte_count", &self.request_byte_count) + .field("write_timeout", &self.write_timeout) + .finish() } } @@ -131,80 +374,2101 @@ impl WebDriverBiDiWebSocketEstablished { /// Borrow the exact verified transport evidence retained with this live stream. #[must_use] pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { - self.0.transport_evidence() + &self.transport_evidence } /// Borrow the exact client key correlated with the validated server accept value. #[must_use] pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { - self.0.client_key() + &self.client_key } /// Return the validated HTTP status code, currently always `101` on success. #[must_use] pub const fn response_status(&self) -> u16 { - self.0.response_status() + self.response_status } /// Return the number of HTTP opening-response bytes consumed through its header terminator. #[must_use] pub const fn response_byte_count(&self) -> usize { - self.0.response_byte_count() + self.response_byte_count } /// Return the total response deadline configured for this opening response. #[must_use] pub const fn response_timeout(&self) -> Duration { - self.0.response_timeout() + self.response_timeout } /// Return the number of request bytes written before the response was read. #[must_use] pub const fn request_byte_count(&self) -> usize { - self.0.request_byte_count() + self.request_byte_count } /// Return the total write deadline configured for the preceding opening request. #[must_use] pub const fn write_timeout(&self) -> Duration { - self.0.write_timeout() + self.write_timeout } /// Write one unfragmented, masked UTF-8 text frame on this verified stream. + /// + /// The operation consumes the established state and returns it only after the complete frame + /// is written and the temporary socket timeout is cleared. The caller must provide a fresh, + /// unpredictable masking key for this frame; it is never exposed in evidence or debug output. + /// This method does not translate JSON, create a BiDi session, or grant browser/Agent authority. pub fn write_text_frame( self, text: &str, masking_key: WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { - self.0 - .write_text_frame(text, masking_key, frame_timeout) - .map(Self) + validate_frame_timeout(frame_timeout)?; + if text.len() > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES { + return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: text.len(), + maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES, + }); + } + + let frame = serialize_text_frame(text.as_bytes(), masking_key); + let Self { + mut stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + } = self; + let mut now = Instant::now; + write_frame_with_clock(&mut stream, &frame, frame_timeout, &mut now)?; + Ok(Self { + stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + }) } - /// Read one bounded RFC 6455 frame and reject close status codes forbidden on the wire. + /// Read one bounded RFC 6455 frame from this verified stream. + /// + /// Server-to-client frames must be unmasked. Data and continuation frames are returned one at + /// a time so a later message layer can enforce fragmentation and JSON semantics; control frames + /// are returned to that layer for protocol handling. Reserved bits/opcodes, oversized payloads, + /// noncanonical lengths, and incomplete reads fail closed. Close frames additionally enforce the + /// RFC 6455 payload shape and UTF-8 reason contract before the frame is returned. No frame grants + /// browser/Agent authority. pub fn read_frame( self, frame_timeout: Duration, ) -> Result<(Self, WebDriverBiDiWebSocketFrame), WebDriverBiDiWebSocketFrameError> { - let (established, frame) = self.0.read_frame(frame_timeout)?; - validate_close_status_code(&frame)?; - Ok((Self(established), frame)) + validate_frame_timeout(frame_timeout)?; + let Self { + mut stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + } = self; + let mut now = Instant::now; + let frame = read_frame_with_clock(&mut stream, frame_timeout, &mut now)?; + Ok(( + Self { + stream, + transport_evidence, + client_key, + response_status, + response_byte_count, + response_timeout, + request_byte_count, + write_timeout, + }, + frame, + )) } } -fn validate_close_status_code( - frame: &WebDriverBiDiWebSocketFrame, -) -> Result<(), WebDriverBiDiWebSocketFrameError> { - if frame.opcode() != 0x8 || frame.payload().len() < 2 { - return Ok(()); +/// One validated WebSocket frame received from the established peer. +#[derive(Debug, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketFrame { + fin: bool, + opcode: u8, + payload: Vec, +} + +impl WebDriverBiDiWebSocketFrame { + /// Return whether this is the final frame in its message. + #[must_use] + pub const fn fin(&self) -> bool { + self.fin + } + + /// Return the RFC 6455 opcode without interpreting application semantics. + #[must_use] + pub const fn opcode(&self) -> u8 { + self.opcode + } + + /// Borrow the bounded, unmasked application payload. + #[must_use] + pub fn payload(&self) -> &[u8] { + &self.payload + } +} + +fn validate_frame_timeout(frame_timeout: Duration) -> Result<(), WebDriverBiDiWebSocketFrameError> { + if frame_timeout.is_zero() || frame_timeout > MAX_WEBSOCKET_FRAME_TIMEOUT { + return Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }); + } + Ok(()) +} + +/// Fail-closed errors while reading or writing one bounded WebSocket frame. +#[derive(Debug)] +pub enum WebDriverBiDiWebSocketFrameError { + /// The requested frame I/O deadline was zero or above the reviewed resource ceiling. + InvalidFrameTimeout { + /// Rejected caller-supplied deadline. + frame_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The frame payload exceeded the reviewed memory ceiling. + FrameTooLarge { + /// Rejected payload length in bytes. + payload_bytes: usize, + /// Maximum payload length admitted by this boundary. + maximum_bytes: usize, + }, + /// Applying the operation-local nonblocking read mode failed. + FrameReadModeConfigurationFailed { + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket read timed out before the frame was complete. + FrameReadTimedOut { + /// Number of frame bytes consumed before timeout. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A non-recoverable socket read failed before the frame was complete. + FrameReadFailed { + /// Number of frame bytes consumed before failure. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// The peer ended the stream before the frame was complete. + FrameEnded { + /// Number of frame bytes consumed before EOF. + bytes_read: usize, + }, + /// The frame header or RFC 6455 control-frame payload violated the protocol contract. + MalformedFrame { + /// Stable, non-secret reason for rejection. + reason: &'static str, + }, + /// Applying the operation-local write timeout failed. + FrameWriteModeConfigurationFailed { + /// Number of frame bytes already written before configuration failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket write timed out before the frame was complete. + FrameWriteTimedOut { + /// Number of frame bytes written before timeout. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A non-recoverable socket write failed before the frame was complete. + FrameWriteFailed { + /// Number of frame bytes written before failure. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// The stream reported zero progress before the frame was complete. + FrameWriteZero { + /// Number of frame bytes written before zero progress. + bytes_written: usize, + }, + /// Clearing the temporary write timeout failed before handoff. + FrameWriteCleanupFailed { + /// Underlying operating-system error. + source: io::Error, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketFrameError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFrameTimeout { .. } => formatter + .write_str("WebDriver BiDi WebSocket frame timeout is outside the reviewed bound"), + Self::FrameTooLarge { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame payload exceeded its bound") + } + Self::FrameReadModeConfigurationFailed { .. } => { + formatter.write_str("failed to configure bounded WebSocket frame reads") + } + Self::FrameReadTimedOut { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame read timed out") + } + Self::FrameReadFailed { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame read failed") + } + Self::FrameEnded { .. } => { + formatter.write_str("WebDriver BiDi WebSocket peer ended the frame stream") + } + Self::MalformedFrame { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame was malformed") + } + Self::FrameWriteModeConfigurationFailed { .. } => { + formatter.write_str("failed to configure bounded WebSocket frame writes") + } + Self::FrameWriteTimedOut { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write timed out") + } + Self::FrameWriteFailed { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write failed") + } + Self::FrameWriteZero { .. } => { + formatter.write_str("WebDriver BiDi WebSocket frame write made no progress") + } + Self::FrameWriteCleanupFailed { .. } => { + formatter.write_str("failed to clear the WebDriver BiDi WebSocket frame timeout") + } + } + } +} + +impl Error for WebDriverBiDiWebSocketFrameError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::FrameReadModeConfigurationFailed { source } + | Self::FrameReadTimedOut { source, .. } + | Self::FrameReadFailed { source, .. } + | Self::FrameWriteModeConfigurationFailed { source, .. } + | Self::FrameWriteTimedOut { source, .. } + | Self::FrameWriteFailed { source, .. } + | Self::FrameWriteCleanupFailed { source } => Some(source), + Self::InvalidFrameTimeout { .. } + | Self::FrameTooLarge { .. } + | Self::FrameEnded { .. } + | Self::MalformedFrame { .. } + | Self::FrameWriteZero { .. } => None, + } } +} + +/// Fail-closed errors while reading one bounded WebDriver BiDi WebSocket opening response. +#[derive(Debug)] +pub enum WebDriverBiDiWebSocketHandshakeResponseError { + /// The requested total response deadline was zero or above the reviewed resource ceiling. + InvalidResponseTimeout { + /// Rejected caller-supplied deadline. + response_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The monotonic total response deadline elapsed before validation completed. + ResponseDeadlineExceeded { + /// Number of response bytes consumed before the deadline elapsed. + bytes_read: usize, + }, + /// The response exceeded the reviewed header-size ceiling before its terminator was found. + ResponseTooLarge { + /// Number of response bytes consumed before rejection. + bytes_read: usize, + /// Maximum response bytes admitted by this boundary. + maximum_bytes: usize, + }, + /// Applying the operation-local nonblocking read mode failed. + ResponseReadModeConfigurationFailed { + /// Number of response bytes consumed before configuration failed. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket read timed out before the opening response was complete. + ResponseReadTimedOut { + /// Number of response bytes consumed before the timed-out operation. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A non-recoverable socket read failed before the opening response was complete. + ResponseReadFailed { + /// Number of response bytes consumed before the failure. + bytes_read: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// The peer closed the stream before sending a complete HTTP header block. + ResponseEndedBeforeHeaders { + /// Number of response bytes consumed before the peer closed the stream. + bytes_read: usize, + }, + /// The HTTP response was not a valid, required WebSocket opening response. + MalformedResponse { + /// Stable, non-secret reason for the rejected response shape. + reason: &'static str, + }, + /// The response's `Sec-WebSocket-Accept` did not correlate with the sent client key. + AcceptMismatch, + /// Restoring blocking mode failed after validation. + ReadModeCleanupFailed { + /// Underlying operating-system error. + source: io::Error, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketHandshakeResponseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidResponseTimeout { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response timeout is outside the reviewed bound", + ), + Self::ResponseDeadlineExceeded { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response exceeded its monotonic deadline", + ), + Self::ResponseTooLarge { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response exceeded its bounded header size", + ), + Self::ResponseReadModeConfigurationFailed { .. } => formatter.write_str( + "failed to configure bounded nonblocking WebDriver BiDi WebSocket response reads", + ), + Self::ResponseReadTimedOut { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response timed out before completion", + ), + Self::ResponseReadFailed { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response read failed before completion", + ), + Self::ResponseEndedBeforeHeaders { .. } => formatter.write_str( + "WebDriver BiDi WebSocket peer ended the stream before completing response headers", + ), + Self::MalformedResponse { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening response was malformed or missing a required header", + ), + Self::AcceptMismatch => formatter.write_str( + "WebDriver BiDi WebSocket opening response accept value did not match the client key", + ), + Self::ReadModeCleanupFailed { .. } => formatter.write_str( + "failed to restore blocking WebDriver BiDi WebSocket response reads before handoff", + ), + } + } +} - let status_code = u16::from_be_bytes([frame.payload()[0], frame.payload()[1]]); - if !(1000..=4999).contains(&status_code) || matches!(status_code, 1005 | 1006 | 1015) { +impl Error for WebDriverBiDiWebSocketHandshakeResponseError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::ResponseReadModeConfigurationFailed { source, .. } + | Self::ResponseReadTimedOut { source, .. } + | Self::ResponseReadFailed { source, .. } + | Self::ReadModeCleanupFailed { source } => Some(source), + Self::InvalidResponseTimeout { .. } + | Self::ResponseDeadlineExceeded { .. } + | Self::ResponseTooLarge { .. } + | Self::ResponseEndedBeforeHeaders { .. } + | Self::MalformedResponse { .. } + | Self::AcceptMismatch => None, + } + } +} + +struct ParsedOpeningResponse { + status_code: u16, + byte_count: usize, +} + +fn expected_accept_value(client_key: &WebDriverBiDiWebSocketClientKey) -> String { + let mut digest = Sha1::new(); + digest.update(client_key.as_str().as_bytes()); + digest.update(RFC6455_WEBSOCKET_GUID); + STANDARD.encode(digest.finalize()) +} + +fn is_http_token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) +} + +fn has_header_token(value: &str, expected: &str) -> bool { + value + .split(',') + .map(str::trim) + .any(|token| token.eq_ignore_ascii_case(expected)) +} + +#[allow(clippy::collapsible_if)] +fn parse_opening_response( + response: &[u8], + client_key: &WebDriverBiDiWebSocketClientKey, +) -> Result { + if !response.ends_with(b"\r\n\r\n") { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response is missing its CRLF header terminator", + }, + ); + } + let response_text = std::str::from_utf8(response).map_err(|_| { + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response headers are not valid UTF-8", + } + })?; + let header_text = &response_text[..response_text.len() - 4]; + let (status_line, header_lines) = header_text + .split_once("\r\n") + .map_or((header_text, ""), |(line, rest)| (line, rest)); + if status_line.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "status line contains a control byte", + }, + ); + } + let status_code = status_line + .strip_prefix("HTTP/1.1 ") + .and_then(|rest| rest.split_whitespace().next()) + .and_then(|value| value.parse::().ok()); + if status_code != Some(101) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "status line is not HTTP/1.1 101", + }, + ); + } + + let mut upgrade_has_websocket = false; + let mut connection_has_upgrade = false; + let mut accept = None; + for line in header_lines.split("\r\n") { + if line.is_empty() + || line + .as_bytes() + .first() + .is_some_and(|byte| matches!(byte, b' ' | b'\t')) + { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header line is empty or folded", + }, + ); + } + let (name, value) = line.split_once(':').ok_or( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header line has no colon", + }, + )?; + if name.is_empty() || !name.bytes().all(is_http_token_byte) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header name is not an HTTP token", + }, + ); + } + let value = value.trim_matches([' ', '\t']); + if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "header value contains a control byte", + }, + ); + } + if name.eq_ignore_ascii_case("upgrade") { + upgrade_has_websocket |= has_header_token(value, "websocket"); + } else if name.eq_ignore_ascii_case("connection") { + connection_has_upgrade |= has_header_token(value, "upgrade"); + } else if name.eq_ignore_ascii_case("sec-websocket-accept") { + if accept.is_some() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response repeats the Sec-WebSocket-Accept header", + }, + ); + } + accept = Some(value); + } + } + + if !upgrade_has_websocket { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "Upgrade header does not contain websocket", + }, + ); + } + if !connection_has_upgrade { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "Connection header does not contain Upgrade", + }, + ); + } + let Some(accept) = accept else { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { + reason: "response has no Sec-WebSocket-Accept header", + }, + ); + }; + if accept != expected_accept_value(client_key) { + return Err(WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch); + } + + Ok(ParsedOpeningResponse { + status_code: 101, + byte_count: response.len(), + }) +} + +trait OpeningResponseReader { + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()>; + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result; +} + +impl OpeningResponseReader for TcpStream { + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { + TcpStream::set_nonblocking(self, nonblocking) + } + + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { + self.read(bytes) + } +} + +fn serialize_text_frame(payload: &[u8], masking_key: WebDriverBiDiWebSocketMaskKey) -> Vec { + let mut frame = Vec::with_capacity(payload.len() + 14); + frame.push(0x81); + match payload.len() { + 0..=125 => frame.push(0x80 | payload.len() as u8), + 126..=65_535 => { + frame.push(0x80 | 126); + frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + } + length => { + frame.push(0x80 | 127); + frame.extend_from_slice(&(length as u64).to_be_bytes()); + } + } + frame.extend_from_slice(masking_key.as_bytes()); + frame.extend( + payload.iter().enumerate().map(|(index, byte)| { + byte ^ masking_key.as_bytes()[index % masking_key.as_bytes().len()] + }), + ); + frame +} + +trait FrameWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result; +} + +impl FrameWriter for TcpStream { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + TcpStream::set_write_timeout(self, timeout) + } + + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_frame_with_clock( + writer: &mut dyn FrameWriter, + frame: &[u8], + frame_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + frame_timeout; + let mut bytes_written = 0; + while bytes_written < frame.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written, + source: io::Error::new(io::ErrorKind::TimedOut, "frame write deadline elapsed"), + }); + } + writer + .set_write_timeout(Some(remaining)) + .map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written, + source, + } + })?; + match writer.write_frame_bytes(&frame[bytes_written..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written }); + } + Ok(written) => bytes_written += written, + Err(source) => { + if source.kind() == io::ErrorKind::Interrupted { + continue; + } + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) { + if deadline.saturating_duration_since(now()).is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written, + source, + }); + } + thread::sleep(Duration::from_millis(1)); + continue; + } + return Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written, + source, + }); + } + } + } + writer + .set_write_timeout(None) + .map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { source })?; + Ok(bytes_written) +} + +fn read_frame_with_clock( + reader: &mut dyn OpeningResponseReader, + frame_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + frame_timeout; + reader.set_nonblocking(true).map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { source } + })?; + let mut bytes_read = 0; + let mut header = [0_u8; 2]; + read_frame_bytes_with_clock(reader, &mut header, &mut bytes_read, deadline, now)?; + let first = header[0]; + let second = header[1]; + if first & 0x70 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "reserved frame bits are not negotiated", + }); + } + let fin = first & 0x80 != 0; + let opcode = first & 0x0f; + match opcode { + 0x0..=0x2 => {} + 0x8..=0xa => { + if !fin { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "control frames must not be fragmented", + }); + } + } + _ => { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame opcode is reserved or unsupported", + }); + } + } + if second & 0x80 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "server-to-client frames must not be masked", + }); + } + let length_code = second & 0x7f; + let payload_length = match length_code { + 0..=125 => u64::from(length_code), + 126 => { + let mut extended = [0_u8; 2]; + read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; + let length = u64::from(u16::from_be_bytes(extended)); + if length < 126 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length encoding is not minimal", + }); + } + length + } + _ => { + let mut extended = [0_u8; 8]; + read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; + if extended[0] & 0x80 != 0 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length uses the reserved high bit", + }); + } + let length = u64::from_be_bytes(extended); + if length < 65_536 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "frame length encoding is not minimal", + }); + } + length + } + }; + if payload_length > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64 { + return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: payload_length.min(usize::MAX as u64) as usize, + maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES, + }); + } + if opcode >= 0x8 && payload_length > 125 { return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "Close frame status code is not valid on the wire", + reason: "control frame payload exceeds 125 bytes", }); } + let payload_length = payload_length as usize; + let mut payload = vec![0_u8; payload_length]; + read_frame_bytes_with_clock(reader, &mut payload, &mut bytes_read, deadline, now)?; + if opcode == 0x8 { + if payload.len() == 1 { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "Close frame payload must be empty or begin with a two-byte status code", + }); + } + if payload.len() > 1 && std::str::from_utf8(&payload[2..]).is_err() { + return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "Close frame reason is not valid UTF-8", + }); + } + } + reader.set_nonblocking(false).map_err(|source| { + WebDriverBiDiWebSocketFrameError::FrameReadFailed { bytes_read, source } + })?; + Ok(WebDriverBiDiWebSocketFrame { + fin, + opcode, + payload, + }) +} + +fn read_frame_bytes_with_clock( + reader: &mut dyn OpeningResponseReader, + destination: &mut [u8], + bytes_read: &mut usize, + deadline: Instant, + now: &mut dyn FnMut() -> Instant, +) -> Result<(), WebDriverBiDiWebSocketFrameError> { + let mut offset = 0; + while offset < destination.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: *bytes_read, + source: io::Error::new(io::ErrorKind::TimedOut, "frame read deadline elapsed"), + }); + } + match reader.read_response_bytes(&mut destination[offset..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameEnded { + bytes_read: *bytes_read, + }); + } + Ok(read) if read > destination.len() - offset => { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: *bytes_read, + source: io::Error::new( + io::ErrorKind::InvalidData, + "frame reader returned more bytes than requested", + ), + }); + } + Ok(read) => { + offset += read; + *bytes_read += read; + } + Err(source) if source.kind() == io::ErrorKind::Interrupted => {} + Err(source) + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + if deadline.saturating_duration_since(now()).is_zero() { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: *bytes_read, + source, + }); + } + thread::sleep(Duration::from_millis(1)); + } + Err(source) => { + return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: *bytes_read, + source, + }); + } + } + } Ok(()) } + +fn read_opening_response_with_clock( + reader: &mut dyn OpeningResponseReader, + client_key: &WebDriverBiDiWebSocketClientKey, + response_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { + let deadline = now() + response_timeout; + let mut response = Vec::new(); + + reader.set_nonblocking(true).map_err(|source| { + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 0, + source, + } + })?; + + loop { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: response.len(), + }, + ); + } + if response.len() >= MAX_WEBSOCKET_OPENING_RESPONSE_BYTES { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { + bytes_read: response.len(), + maximum_bytes: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + }, + ); + } + let mut byte = [0_u8; 1]; + match reader.read_response_bytes(&mut byte) { + Ok(0) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { + bytes_read: response.len(), + }, + ); + } + Ok(1) => { + response.push(byte[0]); + if response.ends_with(b"\r\n\r\n") { + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: response.len(), + }, + ); + } + let parsed = parse_opening_response(&response, client_key)?; + reader.set_nonblocking(false).map_err(|source| { + WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { + source, + } + })?; + return Ok((parsed.status_code, parsed.byte_count)); + } + } + Ok(_) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: response.len(), + source: io::Error::new( + io::ErrorKind::InvalidData, + "response reader returned more bytes than requested", + ), + }, + ); + } + Err(source) if source.kind() == io::ErrorKind::Interrupted => {} + Err(source) + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { + bytes_read: response.len(), + source, + }, + ); + } + thread::sleep(Duration::from_millis(1)); + } + Err(source) => { + return Err( + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: response.len(), + source, + }, + ); + } + } + } +} + +/// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. +#[derive(Debug)] +pub enum WebDriverBiDiWebSocketOpeningWriteError { + /// The requested total write deadline was zero or above the reviewed resource ceiling. + InvalidWriteTimeout { + /// Rejected caller-supplied deadline. + write_timeout: Duration, + /// Maximum reviewed deadline accepted by this boundary. + maximum_timeout: Duration, + }, + /// The monotonic total write deadline elapsed before the complete request was written. + WriteDeadlineExceeded { + /// Number of request bytes written before the deadline elapsed. + bytes_written: usize, + }, + /// Applying the remaining operating-system write timeout failed. + WriteTimeoutConfigurationFailed { + /// Number of request bytes already written before configuration failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A bounded socket write reported timeout or would-block before completion. + WriteTimedOut { + /// Number of request bytes written before the timed-out operation. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// A socket write returned zero bytes before the request was complete. + WriteZero { + /// Number of request bytes written before the zero-length write. + bytes_written: usize, + }, + /// A non-recoverable socket write failed before the complete request was emitted. + WriteFailed { + /// Number of request bytes written before the failure. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, + /// Clearing the operation-local socket write timeout failed after all request bytes were sent. + WriteTimeoutCleanupFailed { + /// Number of request bytes already written before cleanup failed. + bytes_written: usize, + /// Underlying operating-system error. + source: io::Error, + }, +} + +impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidWriteTimeout { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound", + ), + Self::WriteDeadlineExceeded { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write exceeded its monotonic deadline", + ), + Self::WriteTimeoutConfigurationFailed { .. } => formatter.write_str( + "failed to configure the bounded WebDriver BiDi WebSocket opening write timeout", + ), + Self::WriteTimedOut { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write timed out before the request was complete", + ), + Self::WriteZero { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write returned zero before the request was complete", + ), + Self::WriteFailed { .. } => formatter.write_str( + "WebDriver BiDi WebSocket opening write failed before the request was complete", + ), + Self::WriteTimeoutCleanupFailed { .. } => formatter.write_str( + "failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff", + ), + } + } +} + +impl Error for WebDriverBiDiWebSocketOpeningWriteError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::WriteTimeoutConfigurationFailed { source, .. } + | Self::WriteTimedOut { source, .. } + | Self::WriteFailed { source, .. } + | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), + Self::InvalidWriteTimeout { .. } + | Self::WriteDeadlineExceeded { .. } + | Self::WriteZero { .. } => None, + } + } +} + +trait OpeningRequestWriter { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; + fn clear_write_timeout(&self) -> io::Result<()>; + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; +} + +impl OpeningRequestWriter for TcpStream { + fn set_write_timeout(&self, timeout: Duration) -> io::Result<()> { + TcpStream::set_write_timeout(self, Some(timeout)) + } + + fn clear_write_timeout(&self) -> io::Result<()> { + TcpStream::set_write_timeout(self, None) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write(bytes) + } +} + +fn write_request_with_clock( + writer: &mut dyn OpeningRequestWriter, + request: &[u8], + write_timeout: Duration, + now: &mut dyn FnMut() -> Instant, +) -> Result { + let deadline = now() + write_timeout; + let mut bytes_written = 0; + + while bytes_written < request.len() { + let remaining = deadline.saturating_duration_since(now()); + if remaining.is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, + ); + } + writer.set_write_timeout(remaining).map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written, + source, + } + })?; + + match writer.write_request_bytes(&request[bytes_written..]) { + Ok(0) => { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); + } + Ok(count) => { + bytes_written += count; + if deadline.saturating_duration_since(now()).is_zero() { + return Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written, + }, + ); + } + } + Err(source) => { + if source.kind() == io::ErrorKind::Interrupted { + continue; + } + if matches!( + source.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) { + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written, + source, + }); + } + return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written, + source, + }); + } + } + } + + writer.clear_write_timeout().map_err(|source| { + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written, + source, + } + })?; + + Ok(bytes_written) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod opening_write_tests { + use super::*; + use std::{ + collections::VecDeque, + net::{Shutdown, TcpListener}, + thread, + }; + + use originweave_core::WebDriverBiDiWebSocketEndpoint; + + #[derive(Debug)] + enum WriteAction { + Count(usize), + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeWriter { + timeout_error: Option, + clear_timeout_error: Option, + actions: VecDeque, + } + + impl FakeWriter { + fn new(actions: impl IntoIterator) -> Self { + Self { + timeout_error: None, + clear_timeout_error: None, + actions: actions.into_iter().collect(), + } + } + } + + impl OpeningRequestWriter for FakeWriter { + fn set_write_timeout(&self, _timeout: Duration) -> io::Result<()> { + if let Some(kind) = self.timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn clear_write_timeout(&self) -> io::Result<()> { + if let Some(kind) = self.clear_timeout_error { + return Err(io::Error::from(kind)); + } + Ok(()) + } + + fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { + let action = self + .actions + .pop_front() + .unwrap_or(WriteAction::Count(bytes.len())); + match action { + WriteAction::Count(count) => Ok(count.min(bytes.len())), + WriteAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + impl FrameWriter for FakeWriter { + fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + let error = if timeout.is_some() { + self.timeout_error + } else { + self.clear_timeout_error + }; + error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) + } + + fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { + self.write_request_bytes(bytes) + } + } + + #[derive(Clone, Debug)] + enum ReadAction { + Byte(u8), + Count(usize), + End, + Error(io::ErrorKind), + } + + #[derive(Debug)] + struct FakeReader { + actions: VecDeque, + mode_error: Option, + cleanup_error: Option, + } + + impl FakeReader { + fn new(actions: impl IntoIterator) -> Self { + Self { + actions: actions.into_iter().collect(), + mode_error: None, + cleanup_error: None, + } + } + } + + impl OpeningResponseReader for FakeReader { + fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { + let error = if nonblocking { + self.mode_error + } else { + self.cleanup_error + }; + error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) + } + + fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { + match self.actions.pop_front().unwrap_or(ReadAction::End) { + ReadAction::Byte(byte) => { + bytes[0] = byte; + Ok(1) + } + ReadAction::Count(count) => Ok(count), + ReadAction::End => Ok(0), + ReadAction::Error(kind) => Err(io::Error::from(kind)), + } + } + } + + fn client_key() -> WebDriverBiDiWebSocketClientKey { + WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ==") + .expect("test client key must be valid") + } + + fn valid_response() -> Vec { + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec() + } + + fn byte_actions(bytes: &[u8]) -> Vec { + bytes.iter().copied().map(ReadAction::Byte).collect() + } + + fn is_malformed_response(response: &[u8], key: &WebDriverBiDiWebSocketClientKey) -> bool { + matches!( + parse_opening_response(response, key), + Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { .. }) + ) + } + + fn read_with_fake( + reader: &mut FakeReader, + now_values: impl IntoIterator, + ) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { + let key = client_key(); + let fallback = Instant::now(); + let mut now_values = now_values.into_iter(); + let mut now = || now_values.next().unwrap_or(fallback); + read_opening_response_with_clock(reader, &key, Duration::from_secs(1), &mut now) + } + + fn read_frame_with_fake( + reader: &mut FakeReader, + now_values: impl IntoIterator, + ) -> Result { + let fallback = Instant::now(); + let mut now_values = now_values.into_iter(); + let mut now = || now_values.next().unwrap_or(fallback); + read_frame_with_clock(reader, Duration::from_secs(1), &mut now) + } + + #[test] + fn parser_accepts_case_insensitive_upgrade_tokens_and_rejects_malformed_headers() { + let key = client_key(); + let response = b"HTTP/1.1 101 Switching Protocols\r\nUpGrAdE: WebSocket\r\nConnection: keep-alive, Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nX-Test: retained\r\n\r\n"; + let parsed = parse_opening_response(response, &key).expect("valid response"); + assert_eq!(parsed.status_code, 101); + assert_eq!(parsed.byte_count, response.len()); + assert!(!is_malformed_response(response, &key)); + let same_length_mismatch = String::from_utf8(response.to_vec()) + .expect("valid response fixture") + .replace( + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", + "s3pPLMBiTxaQ9kYGzzhZRbK+xOoX", + ); + assert!(parse_opening_response(same_length_mismatch.as_bytes(), &key).is_err()); + + let malformed_responses = [ + b"HTTP/1.1 101".to_vec(), + vec![0xff, b'\r', b'\n', b'\r', b'\n'], + b"HTTP/1.1 101\0 Switching Protocols\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nBad Header: value\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\n: value\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: web\x01socket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nUpgrade: websocket\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nConnection: Upgrade\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nSec-WebSocket-Accept: one\r\nSec-WebSocket-Accept: two\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: h2c\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: keep-alive\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n".to_vec(), + ]; + for response in malformed_responses { + assert!(is_malformed_response(&response, &key)); + } + } + + #[test] + fn bounded_response_reader_covers_deadlines_size_io_and_cleanup() { + let start = Instant::now(); + + let mut valid_reader = FakeReader::new(byte_actions(&valid_response())); + let valid = read_with_fake(&mut valid_reader, [start]); + assert!(valid.is_ok()); + + let mut malformed_reader = FakeReader::new(byte_actions(b"HTTP/1.1 200 OK\r\n\r\n")); + assert!(read_with_fake(&mut malformed_reader, [start]).is_err()); + + let mut interrupted_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) + .chain(byte_actions(&valid_response())), + ); + assert!(read_with_fake(&mut interrupted_reader, [start]).is_ok()); + + let mut mode_error_reader = FakeReader::new([]); + mode_error_reader.mode_error = Some(io::ErrorKind::InvalidInput); + assert!(read_with_fake(&mut mode_error_reader, [start]).is_err()); + + let mut ended_reader = FakeReader::new([ReadAction::End]); + assert!(read_with_fake(&mut ended_reader, [start]).is_err()); + + let mut count_reader = FakeReader::new([ReadAction::Count(2)]); + assert!(read_with_fake(&mut count_reader, [start]).is_err()); + + let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); + assert!(read_with_fake(&mut failed_reader, [start]).is_err()); + + let mut retrying_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::WouldBlock)) + .chain(byte_actions(&valid_response())), + ); + assert!(read_with_fake(&mut retrying_reader, [start]).is_ok()); + + let mut timed_out_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::TimedOut)]); + assert!( + read_with_fake( + &mut timed_out_reader, + [start, start, start + Duration::from_secs(1)] + ) + .is_err() + ); + + let mut deadline_reader = FakeReader::new([ReadAction::End]); + assert!( + read_with_fake( + &mut deadline_reader, + [start, start + Duration::from_secs(1)] + ) + .is_err() + ); + + let mut late_response_reader = FakeReader::new(byte_actions(&valid_response())); + let mut late_response_times = vec![start; valid_response().len() + 1]; + late_response_times.push(start + Duration::from_secs(1)); + assert!(read_with_fake(&mut late_response_reader, late_response_times).is_err()); + + let mut cleanup_reader = FakeReader::new(byte_actions(&valid_response())); + cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); + assert!(read_with_fake(&mut cleanup_reader, [start]).is_err()); + + let mut too_large_reader = FakeReader::new(std::iter::repeat_n( + ReadAction::Byte(b'a'), + MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, + )); + assert!(read_with_fake(&mut too_large_reader, [start]).is_err()); + } + + #[test] + fn response_errors_have_deterministic_messages_and_sources() { + let source = io::Error::from(io::ErrorKind::InvalidInput); + let errors = [ + WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { + response_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { + bytes_read: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { + bytes_read: 1, + maximum_bytes: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { + bytes_read: 1, + }, + WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "test" }, + WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch, + WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { source }, + ]; + for (error, has_source) in errors.iter().zip([ + false, false, false, true, true, true, false, false, false, true, + ]) { + assert!(!error.to_string().is_empty()); + assert_eq!(error.source().is_some(), has_source); + } + } + + #[test] + fn bounded_writer_completes_partial_and_interrupted_writes() { + let mut writer = FakeWriter::new([ + WriteAction::Count(2), + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(3), + ]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start, start]); + let mut now = || times.pop_front().unwrap_or(start); + let result = + write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); + let is_five = |candidate: Result| { + matches!(candidate, Ok(5)) + }; + assert!(is_five(result)); + assert!(!is_five(Ok(4))); + } + + fn join_loopback_server(server: thread::JoinHandle>) -> bool { + match server.join() { + Ok(result) => { + result.expect("loopback server must accept the client"); + false + } + Err(_) => true, + } + } + + #[test] + fn bounded_writer_clears_real_socket_timeout_before_success() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let server = thread::spawn(move || listener.accept().map(|_| ())); + let mut stream = TcpStream::connect(address).expect("test client must connect"); + let start = Instant::now(); + let mut now = || start; + + let request_byte_count = + write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) + .expect("the opening request must be written"); + + assert_eq!(request_byte_count, 7); + assert_eq!( + stream + .write_timeout() + .expect("the socket timeout must be inspectable"), + None + ); + assert!(!join_loopback_server(server)); + } + + #[test] + fn panicked_loopback_server_is_reported() { + let server = thread::spawn(|| -> io::Result<()> { + std::panic::resume_unwind(Box::new("intentional test-only server panic")); + }); + + assert!(join_loopback_server(server)); + } + + #[test] + fn bounded_writer_rejects_cleanup_failure_without_success_handoff() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); + let start = Instant::now(); + let mut now = || start; + + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_cleanup_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + .. + } + ) + ) + }; + assert!(is_cleanup_failure(result)); + assert!(!is_cleanup_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); + } + + #[test] + fn bounded_writer_rejects_completion_observed_after_total_deadline() { + let mut writer = FakeWriter::new([WriteAction::Count(1)]); + let start = Instant::now(); + let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); + let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); + let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_deadline_after_one = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 1 + } + ) + ) + }; + assert!(is_deadline_after_one(result)); + assert!(!is_deadline_after_one(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } + ))); + } + + #[test] + fn bounded_writer_classifies_deadline_timeout_zero_and_io_failures() { + let start = Instant::now(); + + let mut deadline_writer = FakeWriter::new([]); + let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); + let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); + let deadline = write_request_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + &mut deadline_now, + ); + let is_deadline_before_write = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { + bytes_written: 0 + } + ) + ) + }; + assert!(is_deadline_before_write(deadline)); + assert!(!is_deadline_before_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + + let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); + let mut zero_now = || start; + let zero = write_request_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + &mut zero_now, + ); + let is_zero_write = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) + ) + }; + assert!(is_zero_write(zero)); + assert!(!is_zero_write(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } + ))); + + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut writer = FakeWriter::new([WriteAction::Error(kind)]); + let mut now = || start; + let timed_out = + write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); + let is_timed_out = + |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_timed_out(timed_out)); + assert!(!is_timed_out(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + source: io::Error::from(kind), + } + ))); + } + + let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let mut failed_now = || start; + let failed = write_request_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + &mut failed_now, + ); + let is_failed = |candidate: Result| { + matches!( + candidate, + Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 0, + .. + }) + ) + }; + assert!(is_failed(failed)); + assert!(!is_failed(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + + let mut configuration_writer = FakeWriter::new([]); + configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let mut configuration_now = || start; + let configuration = write_request_with_clock( + &mut configuration_writer, + b"x", + Duration::from_secs(1), + &mut configuration_now, + ); + let is_configuration_failure = + |candidate: Result| { + matches!( + candidate, + Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 0, + .. + } + ) + ) + }; + assert!(is_configuration_failure(configuration)); + assert!(!is_configuration_failure(Err( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } + ))); + } + + #[test] + fn opening_write_errors_have_deterministic_messages_and_sources() { + let invalid = WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, + }; + let deadline = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; + let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }; + let zero = WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 }; + let failed = WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }; + let cleanup = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + + assert!(!invalid.to_string().is_empty()); + assert!(!deadline.to_string().is_empty()); + assert!(!configure.to_string().is_empty()); + assert!(!timed_out.to_string().is_empty()); + assert!(!zero.to_string().is_empty()); + assert!(!failed.to_string().is_empty()); + assert!(!cleanup.to_string().is_empty()); + assert!(invalid.source().is_none()); + assert!(deadline.source().is_none()); + assert!(configure.source().is_some()); + assert!(timed_out.source().is_some()); + assert!(zero.source().is_none()); + assert!(failed.source().is_some()); + assert!(cleanup.source().is_some()); + } + + #[test] + fn frame_codec_reader_writer_and_errors_are_fully_bounded() { + let masking_key = WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]); + assert_eq!(masking_key.as_bytes(), &[0x37, 0xfa, 0x21, 0x3d]); + for payload in [vec![b'x'; 125], vec![b'x'; 126], vec![b'x'; 65_536]] { + let frame = serialize_text_frame(&payload, masking_key); + assert_eq!(frame[0], 0x81); + assert_ne!(frame[1] & 0x80, 0); + let mask_offset = match payload.len() { + 0..=125 => 2, + 126..=65_535 => 4, + _ => 10, + }; + assert_eq!(&frame[mask_offset..mask_offset + 4], masking_key.as_bytes()); + } + + let start = Instant::now(); + let valid = [0x81, 0x01, b'x']; + let mut valid_reader = FakeReader::new(byte_actions(&valid)); + let valid_frame = read_frame_with_fake(&mut valid_reader, [start]).expect("valid frame"); + assert!(valid_frame.fin()); + assert_eq!(valid_frame.opcode(), 0x1); + assert_eq!(valid_frame.payload(), b"x"); + + let mut ping_reader = FakeReader::new([ReadAction::Byte(0x89), ReadAction::Byte(0)]); + let ping = read_frame_with_fake(&mut ping_reader, [start]).expect("ping frame"); + assert!(ping.fin()); + assert_eq!(ping.opcode(), 0x9); + + let mut continuation_reader = + FakeReader::new([ReadAction::Byte(0x00), ReadAction::Byte(0)]); + let continuation = + read_frame_with_fake(&mut continuation_reader, [start]).expect("continuation frame"); + assert!(!continuation.fin()); + assert_eq!(continuation.opcode(), 0); + + let mut extended_16 = FakeReader::new( + byte_actions(&[0x81, 126, 0, 126]) + .into_iter() + .chain([ReadAction::Count(126)]), + ); + assert_eq!( + read_frame_with_fake(&mut extended_16, [start]) + .expect("extended frame") + .payload() + .len(), + 126 + ); + let mut extended_64 = FakeReader::new( + byte_actions(&[0x81, 127, 0, 0, 0, 0, 0, 1, 0, 0]) + .into_iter() + .chain([ReadAction::Count(65_536)]), + ); + assert_eq!( + read_frame_with_fake(&mut extended_64, [start]) + .expect("large extended frame") + .payload() + .len(), + 65_536 + ); + let mut extended_16_error = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(126), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut extended_16_error, [start]).is_err()); + let mut extended_64_error = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(127), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut extended_64_error, [start]).is_err()); + + let mut oversized_header = vec![0x81, 127]; + oversized_header + .extend_from_slice(&((MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64) + 1).to_be_bytes()); + let mut malformed_readers = vec![ + vec![0xc1, 0], + vec![0x09, 0], + vec![0x83, 0], + vec![0x81, 0x80], + vec![0x81, 126, 0, 1], + vec![0x81, 127, 0x80, 0, 0, 0, 0, 0, 0, 0], + vec![0x81, 127, 0, 0, 0, 0, 0, 0, 0xff, 0xff], + vec![0x89, 126, 0, 126], + oversized_header, + ]; + for bytes in malformed_readers.drain(..) { + let mut reader = FakeReader::new(byte_actions(&bytes)); + assert!(read_frame_with_fake(&mut reader, [start]).is_err()); + } + let mut count_reader = FakeReader::new([ReadAction::Count(3)]); + assert!(read_frame_with_fake(&mut count_reader, [start]).is_err()); + let mut ended_reader = FakeReader::new([ReadAction::Byte(0x81), ReadAction::End]); + assert!(read_frame_with_fake(&mut ended_reader, [start]).is_err()); + let mut interrupted_reader = FakeReader::new( + std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) + .chain(byte_actions(&valid)), + ); + assert!(read_frame_with_fake(&mut interrupted_reader, [start]).is_ok()); + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut retrying_reader = FakeReader::new( + std::iter::once(ReadAction::Error(kind)).chain(byte_actions(&valid)), + ); + assert!(read_frame_with_fake(&mut retrying_reader, [start]).is_ok()); + } + let mut payload_error_reader = FakeReader::new([ + ReadAction::Byte(0x81), + ReadAction::Byte(1), + ReadAction::Error(io::ErrorKind::BrokenPipe), + ]); + assert!(read_frame_with_fake(&mut payload_error_reader, [start]).is_err()); + let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); + assert!(read_frame_with_fake(&mut failed_reader, [start]).is_err()); + let mut mode_reader = FakeReader::new([]); + mode_reader.mode_error = Some(io::ErrorKind::InvalidInput); + assert!(read_frame_with_fake(&mut mode_reader, [start]).is_err()); + let mut timeout_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::WouldBlock)]); + assert!( + read_frame_with_fake( + &mut timeout_reader, + [start, start, start + Duration::from_secs(1)] + ) + .is_err() + ); + let mut deadline_reader = FakeReader::new([]); + assert!( + read_frame_with_fake( + &mut deadline_reader, + [start, start + Duration::from_secs(1)] + ) + .is_err() + ); + let mut cleanup_reader = FakeReader::new(byte_actions(&valid)); + cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); + assert!(read_frame_with_fake(&mut cleanup_reader, [start]).is_err()); + + let mut writer = FakeWriter::new([ + WriteAction::Count(1), + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(99), + ]); + let mut now = || start; + assert_eq!( + write_frame_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now) + .expect("frame write"), + 5 + ); + let mut empty_writer = FakeWriter::new([]); + let mut empty_now = || start; + assert_eq!( + write_frame_with_clock( + &mut empty_writer, + b"", + Duration::from_secs(1), + &mut empty_now + ) + .expect("empty frame write"), + 0 + ); + let mut deadline_writer = FakeWriter::new([]); + let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); + let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); + assert!( + write_frame_with_clock( + &mut deadline_writer, + b"x", + Duration::from_secs(1), + &mut deadline_now + ) + .is_err() + ); + let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); + let mut zero_now = || start; + assert!( + write_frame_with_clock( + &mut zero_writer, + b"x", + Duration::from_secs(1), + &mut zero_now + ) + .is_err() + ); + for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { + let mut writer = FakeWriter::new([WriteAction::Error(kind)]); + let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); + let mut now = || times.pop_front().unwrap_or(start); + assert!( + write_frame_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now) + .is_err() + ); + } + let mut retrying_writer = FakeWriter::new([ + WriteAction::Error(io::ErrorKind::WouldBlock), + WriteAction::Count(1), + ]); + let mut retrying_now = || start; + assert_eq!( + write_frame_with_clock( + &mut retrying_writer, + b"x", + Duration::from_secs(1), + &mut retrying_now + ) + .expect("retrying frame write"), + 1 + ); + let mut interrupted_writer = FakeWriter::new([ + WriteAction::Error(io::ErrorKind::Interrupted), + WriteAction::Count(1), + ]); + let mut interrupted_now = || start; + assert_eq!( + write_frame_with_clock( + &mut interrupted_writer, + b"x", + Duration::from_secs(1), + &mut interrupted_now + ) + .expect("interrupted frame write"), + 1 + ); + let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); + let mut failed_now = || start; + assert!( + write_frame_with_clock( + &mut failed_writer, + b"x", + Duration::from_secs(1), + &mut failed_now + ) + .is_err() + ); + let mut configuration_writer = FakeWriter::new([]); + configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); + let mut configuration_now = || start; + assert!( + write_frame_with_clock( + &mut configuration_writer, + b"x", + Duration::from_secs(1), + &mut configuration_now + ) + .is_err() + ); + let mut cleanup_writer = FakeWriter::new([WriteAction::Count(1)]); + cleanup_writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); + let mut cleanup_now = || start; + assert!( + write_frame_with_clock( + &mut cleanup_writer, + b"x", + Duration::from_secs(1), + &mut cleanup_now + ) + .is_err() + ); + + for timeout in [ + Duration::ZERO, + MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1), + ] { + assert!(validate_frame_timeout(timeout).is_err()); + } + let errors = [ + WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes: 2, + maximum_bytes: 1, + }, + WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketFrameError::FrameReadFailed { + bytes_read: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketFrameError::FrameEnded { bytes_read: 1 }, + WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "test" }, + WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteFailed { + bytes_written: 1, + source: io::Error::from(io::ErrorKind::BrokenPipe), + }, + WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written: 1 }, + WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { + source: io::Error::from(io::ErrorKind::InvalidInput), + }, + ]; + for (error, has_source) in errors.iter().zip([ + false, false, true, true, true, false, false, true, true, true, false, true, + ]) { + assert!(!error.to_string().is_empty()); + assert_eq!(error.source().is_some(), has_source); + } + } + + #[test] + fn established_frame_write_discards_locally_revoked_streams() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("test server must accept"); + stream + .write_all(&valid_response()) + .expect("test server must write response"); + }); + + let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://{address}/session/01234567-89ab-cdef-0123-456789abcdef" + )) + .expect("test endpoint must be valid"); + let correlated = endpoint + .correlate_session_id("01234567-89ab-cdef-0123-456789abcdef") + .expect("test session must correlate"); + let target = correlated + .into_explicit_connect_target() + .expect("test target must be explicit"); + let connection = + crate::WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("test connection plan must be valid") + .connect() + .expect("test connection must succeed"); + let sent = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) + .expect("test handshake plan must be valid") + .write_opening_request(Duration::from_secs(1)) + .expect("test opening request must be written"); + let established = sent + .read_opening_response(Duration::from_secs(1)) + .expect("test opening response must be valid"); + let _ = established.stream.shutdown(Shutdown::Both); + assert!( + established + .write_text_frame( + "x", + WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]), + Duration::from_secs(1), + ) + .is_err() + ); + assert!(server.join().is_ok()); + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake/transport_impl.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake/transport_impl.rs deleted file mode 100644 index 4e42217f9..000000000 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake/transport_impl.rs +++ /dev/null @@ -1,2474 +0,0 @@ -use std::{ - error::Error, - fmt, - io::{self, Read, Write}, - net::TcpStream, - thread, - time::{Duration, Instant}, -}; - -use base64::{Engine, engine::general_purpose::STANDARD}; -use originweave_core::VerifiedWebDriverBiDiSocketPeer; -use sha1::{Digest, Sha1}; - -use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; - -const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; -const RFC6455_WEBSOCKET_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; -const MAX_WEBSOCKET_OPENING_RESPONSE_BYTES: usize = 16 * 1024; -const MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES: usize = 1024 * 1024; - -/// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. -/// -/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. The request is -/// already bounded before this budget is applied. Callers may choose any smaller nonzero deadline. -pub const MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT: Duration = Duration::from_secs(5); - -/// Maximum wall-clock budget accepted for reading one bounded WebSocket opening response. -/// -/// This is an OriginWeave resource-safety ceiling, not an RFC 6455 protocol limit. Callers may -/// choose any smaller nonzero deadline. -pub const MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); - -/// Maximum bytes admitted while reading one WebSocket HTTP opening response. -/// -/// The response is consumed only through its terminating `CRLF CRLF`; WebSocket frames are not -/// read or interpreted by this boundary. -pub const MAX_WEBSOCKET_OPENING_RESPONSE_SIZE: usize = MAX_WEBSOCKET_OPENING_RESPONSE_BYTES; - -/// Maximum payload bytes admitted for one WebSocket frame. -pub const MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE: usize = MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES; - -/// Maximum wall-clock budget accepted for one bounded WebSocket frame I/O operation. -pub const MAX_WEBSOCKET_FRAME_TIMEOUT: Duration = Duration::from_secs(5); - -fn is_base64_data_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') -} - -fn is_canonical_16_byte_base64(value: &str) -> bool { - let bytes = value.as_bytes(); - bytes.len() == WEBSOCKET_CLIENT_KEY_LENGTH - && bytes[..22].iter().copied().all(is_base64_data_byte) - && matches!(bytes[21], b'A' | b'Q' | b'g' | b'w') - && bytes[22] == b'=' - && bytes[23] == b'=' -} - -/// Deterministic failures while preparing one WebDriver BiDi RFC 6455 opening request. -#[derive(Debug, Eq, PartialEq)] -pub enum WebDriverBiDiWebSocketHandshakeError { - /// The supplied client key was not the canonical base64 representation of exactly 16 bytes. - InvalidClientKey, - /// The verified WebDriver BiDi target requires TLS before a WebSocket opening request is sent. - TlsRequired, -} - -impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidClientKey => formatter.write_str( - "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes", - ), - Self::TlsRequired => formatter.write_str( - "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request", - ), - } - } -} - -impl Error for WebDriverBiDiWebSocketHandshakeError {} - -/// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. -/// -/// RFC 6455 requires `Sec-WebSocket-Key` to be a nonce of 16 bytes encoded with base64. This type -/// validates only the canonical wire representation, including zero padding bits. It does not -/// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce -/// for each connection attempt. -#[derive(Debug, Eq, PartialEq)] -pub struct WebDriverBiDiWebSocketClientKey(String); - -impl WebDriverBiDiWebSocketClientKey { - /// Admit one canonical base64 client key representing exactly 16 bytes. - pub fn new(value: &str) -> Result { - if !is_canonical_16_byte_base64(value) { - return Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey); - } - Ok(Self(value.to_owned())) - } - - /// Borrow the exact canonical value for `Sec-WebSocket-Key` serialization. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} - -/// Caller-supplied RFC 6455 mask key for one client-to-server frame. -/// -/// RFC 6455 requires every client frame to carry a fresh, unpredictable four-byte key. This type -/// preserves that requirement at the API boundary without inventing an entropy source; callers must -/// obtain a fresh key from an approved randomness source for every frame. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct WebDriverBiDiWebSocketMaskKey([u8; 4]); - -impl WebDriverBiDiWebSocketMaskKey { - /// Admit one four-byte caller-supplied frame mask key. - #[must_use] - pub const fn new(value: [u8; 4]) -> Self { - Self(value) - } - - /// Borrow the exact four-byte key used on the wire. - #[must_use] - pub const fn as_bytes(&self) -> &[u8; 4] { - &self.0 - } -} - -/// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. -/// -/// The plan consumes the verified TCP connection so the opening request cannot be detached from the -/// socket peer/session evidence that authorized its exact loopback destination. It serializes only -/// the fixed WebSocket version-13 request required for the admitted `/session/` resource -/// and retains the exact client key required to validate a later `Sec-WebSocket-Accept` response. -/// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary -/// before any WebSocket bytes may be written. -/// -/// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` -/// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or -/// Agent-authority grant. -#[derive(Debug)] -pub struct WebDriverBiDiWebSocketHandshakePlan { - connection: WebDriverBiDiTcpConnection, - client_key: WebDriverBiDiWebSocketClientKey, - request: Vec, -} - -impl WebDriverBiDiWebSocketHandshakePlan { - /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. - pub fn new( - connection: WebDriverBiDiTcpConnection, - client_key: WebDriverBiDiWebSocketClientKey, - ) -> Result { - if connection.verified_peer().requires_tls() { - return Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired); - } - - let peer = connection.verified_peer(); - let request = format!( - "GET /session/{} HTTP/1.1\r\nHost: {}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n", - peer.session_id(), - peer.socket_addr(), - client_key.as_str(), - ) - .into_bytes(); - - Ok(Self { - connection, - client_key, - request, - }) - } - - /// Borrow the exact serialized RFC 6455 opening-request bytes. - #[must_use] - pub fn request_bytes(&self) -> &[u8] { - &self.request - } - - /// Borrow the exact client key that a later server-handshake validator must correlate. - #[must_use] - pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { - &self.client_key - } - - /// Borrow the exact peer/session evidence already verified before request construction. - #[must_use] - pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { - self.connection.verified_peer() - } - - /// Write the complete bounded opening request on the exact verified stream within one deadline. - /// - /// The plan is consumed. Zero and over-ceiling deadlines fail closed. The writer retries only an - /// interrupted system call; it never reconnects, resolves a name, selects a proxy, changes the - /// destination, or retries after any other I/O failure. A partial write that cannot finish before - /// the same monotonic deadline is an error and yields no successful handoff. Before success, the - /// operation-local socket write timeout is cleared so the next separately reviewed protocol stage - /// cannot inherit stale timeout authority. Success preserves the live stream, exact transport - /// evidence, and client key for a separately reviewed server handshake validator. It does not - /// read or validate the server response and therefore does not establish WebSocket protocol state - /// or browser/Agent authority. - pub fn write_opening_request( - self, - write_timeout: Duration, - ) -> Result - { - if write_timeout.is_zero() || write_timeout > MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { - write_timeout, - maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, - }, - ); - } - - let Self { - connection, - client_key, - request, - } = self; - let (mut stream, transport_evidence) = connection.into_parts(); - let mut now = Instant::now; - let request_byte_count = - write_request_with_clock(&mut stream, &request, write_timeout, &mut now)?; - - Ok(WebDriverBiDiWebSocketOpeningRequestSent { - stream, - transport_evidence, - client_key, - request_byte_count, - write_timeout, - }) - } -} - -/// A live verified stream after the complete client opening request has been written. -/// -/// This state proves only that the exact bounded RFC 6455 client request reached the operating -/// system's verified TCP stream before the configured deadline and that this operation's socket write -/// timeout was cleared before handoff. It deliberately does not claim that the peer returned `101 -/// Switching Protocols`, that `Sec-WebSocket-Accept` is valid, that a WebSocket is established, or -/// that the peer is the expected Chromium/ChromeDriver process. Those remain separate fail-closed -/// boundaries. -pub struct WebDriverBiDiWebSocketOpeningRequestSent { - pub(crate) stream: TcpStream, - transport_evidence: WebDriverBiDiTcpConnectionEvidence, - client_key: WebDriverBiDiWebSocketClientKey, - request_byte_count: usize, - write_timeout: Duration, -} - -impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("WebDriverBiDiWebSocketOpeningRequestSent") - .field("stream_local_addr", &self.stream.local_addr().ok()) - .field("transport_evidence", &self.transport_evidence) - .field( - "client_key", - &"", - ) - .field("request_byte_count", &self.request_byte_count) - .field("write_timeout", &self.write_timeout) - .finish() - } -} - -impl WebDriverBiDiWebSocketOpeningRequestSent { - /// Borrow the exact verified transport evidence retained with this live stream. - #[must_use] - pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { - &self.transport_evidence - } - - /// Borrow the exact client key required to validate the later server accept value. - #[must_use] - pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { - &self.client_key - } - - /// Return the exact number of opening-request bytes written before success was emitted. - #[must_use] - pub const fn request_byte_count(&self) -> usize { - self.request_byte_count - } - - /// Return the total write deadline configured for this opening request. - #[must_use] - pub const fn write_timeout(&self) -> Duration { - self.write_timeout - } - - /// Read and validate the bounded RFC 6455 server opening response on this exact stream. - /// - /// Success proves only an HTTP/1.1 `101 Switching Protocols` response with the required - /// `Upgrade`, `Connection`, and client-key-correlated `Sec-WebSocket-Accept` headers. The - /// response body, WebSocket frames, browser process identity, TLS, and browser/Agent authority - /// remain separate boundaries. - pub fn read_opening_response( - self, - response_timeout: Duration, - ) -> Result - { - if response_timeout.is_zero() || response_timeout > MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { - response_timeout, - maximum_timeout: MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, - }, - ); - } - - let Self { - mut stream, - transport_evidence, - client_key, - request_byte_count, - write_timeout, - } = self; - let mut now = Instant::now; - let (response_status, response_byte_count) = - read_opening_response_with_clock(&mut stream, &client_key, response_timeout, &mut now)?; - - Ok(WebDriverBiDiWebSocketEstablished { - stream, - transport_evidence, - client_key, - response_status, - response_byte_count, - response_timeout, - request_byte_count, - write_timeout, - }) - } -} - -/// A live verified stream after both RFC 6455 opening messages were validated. -/// -/// This state does not implement WebSocket framing or grant browser, page, policy, or Agent -/// authority. It retains the exact transport evidence and client key so later protocol stages can -/// remain correlated with the verified peer and opening handshake. -pub struct WebDriverBiDiWebSocketEstablished { - pub(crate) stream: TcpStream, - transport_evidence: WebDriverBiDiTcpConnectionEvidence, - client_key: WebDriverBiDiWebSocketClientKey, - response_status: u16, - response_byte_count: usize, - response_timeout: Duration, - request_byte_count: usize, - write_timeout: Duration, -} - -impl fmt::Debug for WebDriverBiDiWebSocketEstablished { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("WebDriverBiDiWebSocketEstablished") - .field("stream_local_addr", &self.stream.local_addr().ok()) - .field("transport_evidence", &self.transport_evidence) - .field( - "client_key", - &"", - ) - .field("response_status", &self.response_status) - .field("response_byte_count", &self.response_byte_count) - .field("response_timeout", &self.response_timeout) - .field("request_byte_count", &self.request_byte_count) - .field("write_timeout", &self.write_timeout) - .finish() - } -} - -impl WebDriverBiDiWebSocketEstablished { - /// Borrow the exact verified transport evidence retained with this live stream. - #[must_use] - pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { - &self.transport_evidence - } - - /// Borrow the exact client key correlated with the validated server accept value. - #[must_use] - pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { - &self.client_key - } - - /// Return the validated HTTP status code, currently always `101` on success. - #[must_use] - pub const fn response_status(&self) -> u16 { - self.response_status - } - - /// Return the number of HTTP opening-response bytes consumed through its header terminator. - #[must_use] - pub const fn response_byte_count(&self) -> usize { - self.response_byte_count - } - - /// Return the total response deadline configured for this opening response. - #[must_use] - pub const fn response_timeout(&self) -> Duration { - self.response_timeout - } - - /// Return the number of request bytes written before the response was read. - #[must_use] - pub const fn request_byte_count(&self) -> usize { - self.request_byte_count - } - - /// Return the total write deadline configured for the preceding opening request. - #[must_use] - pub const fn write_timeout(&self) -> Duration { - self.write_timeout - } - - /// Write one unfragmented, masked UTF-8 text frame on this verified stream. - /// - /// The operation consumes the established state and returns it only after the complete frame - /// is written and the temporary socket timeout is cleared. The caller must provide a fresh, - /// unpredictable masking key for this frame; it is never exposed in evidence or debug output. - /// This method does not translate JSON, create a BiDi session, or grant browser/Agent authority. - pub fn write_text_frame( - self, - text: &str, - masking_key: WebDriverBiDiWebSocketMaskKey, - frame_timeout: Duration, - ) -> Result { - validate_frame_timeout(frame_timeout)?; - if text.len() > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES { - return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { - payload_bytes: text.len(), - maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES, - }); - } - - let frame = serialize_text_frame(text.as_bytes(), masking_key); - let Self { - mut stream, - transport_evidence, - client_key, - response_status, - response_byte_count, - response_timeout, - request_byte_count, - write_timeout, - } = self; - let mut now = Instant::now; - write_frame_with_clock(&mut stream, &frame, frame_timeout, &mut now)?; - Ok(Self { - stream, - transport_evidence, - client_key, - response_status, - response_byte_count, - response_timeout, - request_byte_count, - write_timeout, - }) - } - - /// Read one bounded RFC 6455 frame from this verified stream. - /// - /// Server-to-client frames must be unmasked. Data and continuation frames are returned one at - /// a time so a later message layer can enforce fragmentation and JSON semantics; control frames - /// are returned to that layer for protocol handling. Reserved bits/opcodes, oversized payloads, - /// noncanonical lengths, and incomplete reads fail closed. Close frames additionally enforce the - /// RFC 6455 payload shape and UTF-8 reason contract before the frame is returned. No frame grants - /// browser/Agent authority. - pub fn read_frame( - self, - frame_timeout: Duration, - ) -> Result<(Self, WebDriverBiDiWebSocketFrame), WebDriverBiDiWebSocketFrameError> { - validate_frame_timeout(frame_timeout)?; - let Self { - mut stream, - transport_evidence, - client_key, - response_status, - response_byte_count, - response_timeout, - request_byte_count, - write_timeout, - } = self; - let mut now = Instant::now; - let frame = read_frame_with_clock(&mut stream, frame_timeout, &mut now)?; - Ok(( - Self { - stream, - transport_evidence, - client_key, - response_status, - response_byte_count, - response_timeout, - request_byte_count, - write_timeout, - }, - frame, - )) - } -} - -/// One validated WebSocket frame received from the established peer. -#[derive(Debug, Eq, PartialEq)] -pub struct WebDriverBiDiWebSocketFrame { - fin: bool, - opcode: u8, - payload: Vec, -} - -impl WebDriverBiDiWebSocketFrame { - /// Return whether this is the final frame in its message. - #[must_use] - pub const fn fin(&self) -> bool { - self.fin - } - - /// Return the RFC 6455 opcode without interpreting application semantics. - #[must_use] - pub const fn opcode(&self) -> u8 { - self.opcode - } - - /// Borrow the bounded, unmasked application payload. - #[must_use] - pub fn payload(&self) -> &[u8] { - &self.payload - } -} - -fn validate_frame_timeout(frame_timeout: Duration) -> Result<(), WebDriverBiDiWebSocketFrameError> { - if frame_timeout.is_zero() || frame_timeout > MAX_WEBSOCKET_FRAME_TIMEOUT { - return Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { - frame_timeout, - maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, - }); - } - Ok(()) -} - -/// Fail-closed errors while reading or writing one bounded WebSocket frame. -#[derive(Debug)] -pub enum WebDriverBiDiWebSocketFrameError { - /// The requested frame I/O deadline was zero or above the reviewed resource ceiling. - InvalidFrameTimeout { - /// Rejected caller-supplied deadline. - frame_timeout: Duration, - /// Maximum reviewed deadline accepted by this boundary. - maximum_timeout: Duration, - }, - /// The frame payload exceeded the reviewed memory ceiling. - FrameTooLarge { - /// Rejected payload length in bytes. - payload_bytes: usize, - /// Maximum payload length admitted by this boundary. - maximum_bytes: usize, - }, - /// Applying the operation-local nonblocking read mode failed. - FrameReadModeConfigurationFailed { - /// Underlying operating-system error. - source: io::Error, - }, - /// A bounded socket read timed out before the frame was complete. - FrameReadTimedOut { - /// Number of frame bytes consumed before timeout. - bytes_read: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A non-recoverable socket read failed before the frame was complete. - FrameReadFailed { - /// Number of frame bytes consumed before failure. - bytes_read: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// The peer ended the stream before the frame was complete. - FrameEnded { - /// Number of frame bytes consumed before EOF. - bytes_read: usize, - }, - /// The frame header or RFC 6455 control-frame payload violated the protocol contract. - MalformedFrame { - /// Stable, non-secret reason for rejection. - reason: &'static str, - }, - /// Applying the operation-local write timeout failed. - FrameWriteModeConfigurationFailed { - /// Number of frame bytes already written before configuration failed. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A bounded socket write timed out before the frame was complete. - FrameWriteTimedOut { - /// Number of frame bytes written before timeout. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A non-recoverable socket write failed before the frame was complete. - FrameWriteFailed { - /// Number of frame bytes written before failure. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// The stream reported zero progress before the frame was complete. - FrameWriteZero { - /// Number of frame bytes written before zero progress. - bytes_written: usize, - }, - /// Clearing the temporary write timeout failed before handoff. - FrameWriteCleanupFailed { - /// Underlying operating-system error. - source: io::Error, - }, -} - -impl fmt::Display for WebDriverBiDiWebSocketFrameError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidFrameTimeout { .. } => formatter - .write_str("WebDriver BiDi WebSocket frame timeout is outside the reviewed bound"), - Self::FrameTooLarge { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame payload exceeded its bound") - } - Self::FrameReadModeConfigurationFailed { .. } => { - formatter.write_str("failed to configure bounded WebSocket frame reads") - } - Self::FrameReadTimedOut { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame read timed out") - } - Self::FrameReadFailed { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame read failed") - } - Self::FrameEnded { .. } => { - formatter.write_str("WebDriver BiDi WebSocket peer ended the frame stream") - } - Self::MalformedFrame { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame was malformed") - } - Self::FrameWriteModeConfigurationFailed { .. } => { - formatter.write_str("failed to configure bounded WebSocket frame writes") - } - Self::FrameWriteTimedOut { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame write timed out") - } - Self::FrameWriteFailed { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame write failed") - } - Self::FrameWriteZero { .. } => { - formatter.write_str("WebDriver BiDi WebSocket frame write made no progress") - } - Self::FrameWriteCleanupFailed { .. } => { - formatter.write_str("failed to clear the WebDriver BiDi WebSocket frame timeout") - } - } - } -} - -impl Error for WebDriverBiDiWebSocketFrameError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::FrameReadModeConfigurationFailed { source } - | Self::FrameReadTimedOut { source, .. } - | Self::FrameReadFailed { source, .. } - | Self::FrameWriteModeConfigurationFailed { source, .. } - | Self::FrameWriteTimedOut { source, .. } - | Self::FrameWriteFailed { source, .. } - | Self::FrameWriteCleanupFailed { source } => Some(source), - Self::InvalidFrameTimeout { .. } - | Self::FrameTooLarge { .. } - | Self::FrameEnded { .. } - | Self::MalformedFrame { .. } - | Self::FrameWriteZero { .. } => None, - } - } -} - -/// Fail-closed errors while reading one bounded WebDriver BiDi WebSocket opening response. -#[derive(Debug)] -pub enum WebDriverBiDiWebSocketHandshakeResponseError { - /// The requested total response deadline was zero or above the reviewed resource ceiling. - InvalidResponseTimeout { - /// Rejected caller-supplied deadline. - response_timeout: Duration, - /// Maximum reviewed deadline accepted by this boundary. - maximum_timeout: Duration, - }, - /// The monotonic total response deadline elapsed before validation completed. - ResponseDeadlineExceeded { - /// Number of response bytes consumed before the deadline elapsed. - bytes_read: usize, - }, - /// The response exceeded the reviewed header-size ceiling before its terminator was found. - ResponseTooLarge { - /// Number of response bytes consumed before rejection. - bytes_read: usize, - /// Maximum response bytes admitted by this boundary. - maximum_bytes: usize, - }, - /// Applying the operation-local nonblocking read mode failed. - ResponseReadModeConfigurationFailed { - /// Number of response bytes consumed before configuration failed. - bytes_read: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A bounded socket read timed out before the opening response was complete. - ResponseReadTimedOut { - /// Number of response bytes consumed before the timed-out operation. - bytes_read: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A non-recoverable socket read failed before the opening response was complete. - ResponseReadFailed { - /// Number of response bytes consumed before the failure. - bytes_read: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// The peer closed the stream before sending a complete HTTP header block. - ResponseEndedBeforeHeaders { - /// Number of response bytes consumed before the peer closed the stream. - bytes_read: usize, - }, - /// The HTTP response was not a valid, required WebSocket opening response. - MalformedResponse { - /// Stable, non-secret reason for the rejected response shape. - reason: &'static str, - }, - /// The response's `Sec-WebSocket-Accept` did not correlate with the sent client key. - AcceptMismatch, - /// Restoring blocking mode failed after validation. - ReadModeCleanupFailed { - /// Underlying operating-system error. - source: io::Error, - }, -} - -impl fmt::Display for WebDriverBiDiWebSocketHandshakeResponseError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidResponseTimeout { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response timeout is outside the reviewed bound", - ), - Self::ResponseDeadlineExceeded { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response exceeded its monotonic deadline", - ), - Self::ResponseTooLarge { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response exceeded its bounded header size", - ), - Self::ResponseReadModeConfigurationFailed { .. } => formatter.write_str( - "failed to configure bounded nonblocking WebDriver BiDi WebSocket response reads", - ), - Self::ResponseReadTimedOut { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response timed out before completion", - ), - Self::ResponseReadFailed { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response read failed before completion", - ), - Self::ResponseEndedBeforeHeaders { .. } => formatter.write_str( - "WebDriver BiDi WebSocket peer ended the stream before completing response headers", - ), - Self::MalformedResponse { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening response was malformed or missing a required header", - ), - Self::AcceptMismatch => formatter.write_str( - "WebDriver BiDi WebSocket opening response accept value did not match the client key", - ), - Self::ReadModeCleanupFailed { .. } => formatter.write_str( - "failed to restore blocking WebDriver BiDi WebSocket response reads before handoff", - ), - } - } -} - -impl Error for WebDriverBiDiWebSocketHandshakeResponseError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::ResponseReadModeConfigurationFailed { source, .. } - | Self::ResponseReadTimedOut { source, .. } - | Self::ResponseReadFailed { source, .. } - | Self::ReadModeCleanupFailed { source } => Some(source), - Self::InvalidResponseTimeout { .. } - | Self::ResponseDeadlineExceeded { .. } - | Self::ResponseTooLarge { .. } - | Self::ResponseEndedBeforeHeaders { .. } - | Self::MalformedResponse { .. } - | Self::AcceptMismatch => None, - } - } -} - -struct ParsedOpeningResponse { - status_code: u16, - byte_count: usize, -} - -fn expected_accept_value(client_key: &WebDriverBiDiWebSocketClientKey) -> String { - let mut digest = Sha1::new(); - digest.update(client_key.as_str().as_bytes()); - digest.update(RFC6455_WEBSOCKET_GUID); - STANDARD.encode(digest.finalize()) -} - -fn is_http_token_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() - || matches!( - byte, - b'!' | b'#' - | b'$' - | b'%' - | b'&' - | b'\'' - | b'*' - | b'+' - | b'-' - | b'.' - | b'^' - | b'_' - | b'`' - | b'|' - | b'~' - ) -} - -fn has_header_token(value: &str, expected: &str) -> bool { - value - .split(',') - .map(str::trim) - .any(|token| token.eq_ignore_ascii_case(expected)) -} - -#[allow(clippy::collapsible_if)] -fn parse_opening_response( - response: &[u8], - client_key: &WebDriverBiDiWebSocketClientKey, -) -> Result { - if !response.ends_with(b"\r\n\r\n") { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response is missing its CRLF header terminator", - }, - ); - } - let response_text = std::str::from_utf8(response).map_err(|_| { - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response headers are not valid UTF-8", - } - })?; - let header_text = &response_text[..response_text.len() - 4]; - let (status_line, header_lines) = header_text - .split_once("\r\n") - .map_or((header_text, ""), |(line, rest)| (line, rest)); - if status_line.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "status line contains a control byte", - }, - ); - } - let status_code = status_line - .strip_prefix("HTTP/1.1 ") - .and_then(|rest| rest.split_whitespace().next()) - .and_then(|value| value.parse::().ok()); - if status_code != Some(101) { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "status line is not HTTP/1.1 101", - }, - ); - } - - let mut upgrade_has_websocket = false; - let mut connection_has_upgrade = false; - let mut accept = None; - for line in header_lines.split("\r\n") { - if line.is_empty() - || line - .as_bytes() - .first() - .is_some_and(|byte| matches!(byte, b' ' | b'\t')) - { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "header line is empty or folded", - }, - ); - } - let (name, value) = line.split_once(':').ok_or( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "header line has no colon", - }, - )?; - if name.is_empty() || !name.bytes().all(is_http_token_byte) { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "header name is not an HTTP token", - }, - ); - } - let value = value.trim_matches([' ', '\t']); - if value.bytes().any(|byte| byte < 0x20 || byte == 0x7f) { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "header value contains a control byte", - }, - ); - } - if name.eq_ignore_ascii_case("upgrade") { - upgrade_has_websocket |= has_header_token(value, "websocket"); - } else if name.eq_ignore_ascii_case("connection") { - connection_has_upgrade |= has_header_token(value, "upgrade"); - } else if name.eq_ignore_ascii_case("sec-websocket-accept") { - if accept.is_some() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response repeats the Sec-WebSocket-Accept header", - }, - ); - } - accept = Some(value); - } - } - - if !upgrade_has_websocket { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "Upgrade header does not contain websocket", - }, - ); - } - if !connection_has_upgrade { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "Connection header does not contain Upgrade", - }, - ); - } - let Some(accept) = accept else { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { - reason: "response has no Sec-WebSocket-Accept header", - }, - ); - }; - if accept != expected_accept_value(client_key) { - return Err(WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch); - } - - Ok(ParsedOpeningResponse { - status_code: 101, - byte_count: response.len(), - }) -} - -trait OpeningResponseReader { - fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()>; - fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result; -} - -impl OpeningResponseReader for TcpStream { - fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { - TcpStream::set_nonblocking(self, nonblocking) - } - - fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { - self.read(bytes) - } -} - -fn serialize_text_frame(payload: &[u8], masking_key: WebDriverBiDiWebSocketMaskKey) -> Vec { - let mut frame = Vec::with_capacity(payload.len() + 14); - frame.push(0x81); - match payload.len() { - 0..=125 => frame.push(0x80 | payload.len() as u8), - 126..=65_535 => { - frame.push(0x80 | 126); - frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - } - length => { - frame.push(0x80 | 127); - frame.extend_from_slice(&(length as u64).to_be_bytes()); - } - } - frame.extend_from_slice(masking_key.as_bytes()); - frame.extend( - payload.iter().enumerate().map(|(index, byte)| { - byte ^ masking_key.as_bytes()[index % masking_key.as_bytes().len()] - }), - ); - frame -} - -trait FrameWriter { - fn set_write_timeout(&self, timeout: Option) -> io::Result<()>; - fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result; -} - -impl FrameWriter for TcpStream { - fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { - TcpStream::set_write_timeout(self, timeout) - } - - fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { - self.write(bytes) - } -} - -fn write_frame_with_clock( - writer: &mut dyn FrameWriter, - frame: &[u8], - frame_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result { - let deadline = now() + frame_timeout; - let mut bytes_written = 0; - while bytes_written < frame.len() { - let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { - bytes_written, - source: io::Error::new(io::ErrorKind::TimedOut, "frame write deadline elapsed"), - }); - } - writer - .set_write_timeout(Some(remaining)) - .map_err(|source| { - WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { - bytes_written, - source, - } - })?; - match writer.write_frame_bytes(&frame[bytes_written..]) { - Ok(0) => { - return Err(WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written }); - } - Ok(written) => bytes_written += written, - Err(source) => { - if source.kind() == io::ErrorKind::Interrupted { - continue; - } - if matches!( - source.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) { - if deadline.saturating_duration_since(now()).is_zero() { - return Err(WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { - bytes_written, - source, - }); - } - thread::sleep(Duration::from_millis(1)); - continue; - } - return Err(WebDriverBiDiWebSocketFrameError::FrameWriteFailed { - bytes_written, - source, - }); - } - } - } - writer - .set_write_timeout(None) - .map_err(|source| WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { source })?; - Ok(bytes_written) -} - -fn read_frame_with_clock( - reader: &mut dyn OpeningResponseReader, - frame_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result { - let deadline = now() + frame_timeout; - reader.set_nonblocking(true).map_err(|source| { - WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { source } - })?; - let mut bytes_read = 0; - let mut header = [0_u8; 2]; - read_frame_bytes_with_clock(reader, &mut header, &mut bytes_read, deadline, now)?; - let first = header[0]; - let second = header[1]; - if first & 0x70 != 0 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "reserved frame bits are not negotiated", - }); - } - let fin = first & 0x80 != 0; - let opcode = first & 0x0f; - match opcode { - 0x0..=0x2 => {} - 0x8..=0xa => { - if !fin { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "control frames must not be fragmented", - }); - } - } - _ => { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "frame opcode is reserved or unsupported", - }); - } - } - if second & 0x80 != 0 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "server-to-client frames must not be masked", - }); - } - let length_code = second & 0x7f; - let payload_length = match length_code { - 0..=125 => u64::from(length_code), - 126 => { - let mut extended = [0_u8; 2]; - read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; - let length = u64::from(u16::from_be_bytes(extended)); - if length < 126 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "frame length encoding is not minimal", - }); - } - length - } - _ => { - let mut extended = [0_u8; 8]; - read_frame_bytes_with_clock(reader, &mut extended, &mut bytes_read, deadline, now)?; - if extended[0] & 0x80 != 0 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "frame length uses the reserved high bit", - }); - } - let length = u64::from_be_bytes(extended); - if length < 65_536 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "frame length encoding is not minimal", - }); - } - length - } - }; - if payload_length > MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64 { - return Err(WebDriverBiDiWebSocketFrameError::FrameTooLarge { - payload_bytes: payload_length.min(usize::MAX as u64) as usize, - maximum_bytes: MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES, - }); - } - if opcode >= 0x8 && payload_length > 125 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "control frame payload exceeds 125 bytes", - }); - } - let payload_length = payload_length as usize; - let mut payload = vec![0_u8; payload_length]; - read_frame_bytes_with_clock(reader, &mut payload, &mut bytes_read, deadline, now)?; - if opcode == 0x8 { - if payload.len() == 1 { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "Close frame payload must be empty or begin with a two-byte status code", - }); - } - if payload.len() > 1 && std::str::from_utf8(&payload[2..]).is_err() { - return Err(WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: "Close frame reason is not valid UTF-8", - }); - } - } - reader.set_nonblocking(false).map_err(|source| { - WebDriverBiDiWebSocketFrameError::FrameReadFailed { bytes_read, source } - })?; - Ok(WebDriverBiDiWebSocketFrame { - fin, - opcode, - payload, - }) -} - -fn read_frame_bytes_with_clock( - reader: &mut dyn OpeningResponseReader, - destination: &mut [u8], - bytes_read: &mut usize, - deadline: Instant, - now: &mut dyn FnMut() -> Instant, -) -> Result<(), WebDriverBiDiWebSocketFrameError> { - let mut offset = 0; - while offset < destination.len() { - let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { - bytes_read: *bytes_read, - source: io::Error::new(io::ErrorKind::TimedOut, "frame read deadline elapsed"), - }); - } - match reader.read_response_bytes(&mut destination[offset..]) { - Ok(0) => { - return Err(WebDriverBiDiWebSocketFrameError::FrameEnded { - bytes_read: *bytes_read, - }); - } - Ok(read) if read > destination.len() - offset => { - return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { - bytes_read: *bytes_read, - source: io::Error::new( - io::ErrorKind::InvalidData, - "frame reader returned more bytes than requested", - ), - }); - } - Ok(read) => { - offset += read; - *bytes_read += read; - } - Err(source) if source.kind() == io::ErrorKind::Interrupted => {} - Err(source) - if matches!( - source.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) => - { - if deadline.saturating_duration_since(now()).is_zero() { - return Err(WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { - bytes_read: *bytes_read, - source, - }); - } - thread::sleep(Duration::from_millis(1)); - } - Err(source) => { - return Err(WebDriverBiDiWebSocketFrameError::FrameReadFailed { - bytes_read: *bytes_read, - source, - }); - } - } - } - Ok(()) -} - -fn read_opening_response_with_clock( - reader: &mut dyn OpeningResponseReader, - client_key: &WebDriverBiDiWebSocketClientKey, - response_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { - let deadline = now() + response_timeout; - let mut response = Vec::new(); - - reader.set_nonblocking(true).map_err(|source| { - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { - bytes_read: 0, - source, - } - })?; - - loop { - let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { - bytes_read: response.len(), - }, - ); - } - if response.len() >= MAX_WEBSOCKET_OPENING_RESPONSE_BYTES { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { - bytes_read: response.len(), - maximum_bytes: MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, - }, - ); - } - let mut byte = [0_u8; 1]; - match reader.read_response_bytes(&mut byte) { - Ok(0) => { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { - bytes_read: response.len(), - }, - ); - } - Ok(1) => { - response.push(byte[0]); - if response.ends_with(b"\r\n\r\n") { - if deadline.saturating_duration_since(now()).is_zero() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { - bytes_read: response.len(), - }, - ); - } - let parsed = parse_opening_response(&response, client_key)?; - reader.set_nonblocking(false).map_err(|source| { - WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { - source, - } - })?; - return Ok((parsed.status_code, parsed.byte_count)); - } - } - Ok(_) => { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { - bytes_read: response.len(), - source: io::Error::new( - io::ErrorKind::InvalidData, - "response reader returned more bytes than requested", - ), - }, - ); - } - Err(source) if source.kind() == io::ErrorKind::Interrupted => {} - Err(source) - if matches!( - source.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) => - { - if deadline.saturating_duration_since(now()).is_zero() { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { - bytes_read: response.len(), - source, - }, - ); - } - thread::sleep(Duration::from_millis(1)); - } - Err(source) => { - return Err( - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { - bytes_read: response.len(), - source, - }, - ); - } - } - } -} - -/// Fail-closed errors while writing one bounded WebDriver BiDi WebSocket opening request. -#[derive(Debug)] -pub enum WebDriverBiDiWebSocketOpeningWriteError { - /// The requested total write deadline was zero or above the reviewed resource ceiling. - InvalidWriteTimeout { - /// Rejected caller-supplied deadline. - write_timeout: Duration, - /// Maximum reviewed deadline accepted by this boundary. - maximum_timeout: Duration, - }, - /// The monotonic total write deadline elapsed before the complete request was written. - WriteDeadlineExceeded { - /// Number of request bytes written before the deadline elapsed. - bytes_written: usize, - }, - /// Applying the remaining operating-system write timeout failed. - WriteTimeoutConfigurationFailed { - /// Number of request bytes already written before configuration failed. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A bounded socket write reported timeout or would-block before completion. - WriteTimedOut { - /// Number of request bytes written before the timed-out operation. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// A socket write returned zero bytes before the request was complete. - WriteZero { - /// Number of request bytes written before the zero-length write. - bytes_written: usize, - }, - /// A non-recoverable socket write failed before the complete request was emitted. - WriteFailed { - /// Number of request bytes written before the failure. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, - /// Clearing the operation-local socket write timeout failed after all request bytes were sent. - WriteTimeoutCleanupFailed { - /// Number of request bytes already written before cleanup failed. - bytes_written: usize, - /// Underlying operating-system error. - source: io::Error, - }, -} - -impl fmt::Display for WebDriverBiDiWebSocketOpeningWriteError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidWriteTimeout { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write timeout is outside the reviewed bound", - ), - Self::WriteDeadlineExceeded { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write exceeded its monotonic deadline", - ), - Self::WriteTimeoutConfigurationFailed { .. } => formatter.write_str( - "failed to configure the bounded WebDriver BiDi WebSocket opening write timeout", - ), - Self::WriteTimedOut { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write timed out before the request was complete", - ), - Self::WriteZero { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write returned zero before the request was complete", - ), - Self::WriteFailed { .. } => formatter.write_str( - "WebDriver BiDi WebSocket opening write failed before the request was complete", - ), - Self::WriteTimeoutCleanupFailed { .. } => formatter.write_str( - "failed to clear the WebDriver BiDi WebSocket opening write timeout before handoff", - ), - } - } -} - -impl Error for WebDriverBiDiWebSocketOpeningWriteError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::WriteTimeoutConfigurationFailed { source, .. } - | Self::WriteTimedOut { source, .. } - | Self::WriteFailed { source, .. } - | Self::WriteTimeoutCleanupFailed { source, .. } => Some(source), - Self::InvalidWriteTimeout { .. } - | Self::WriteDeadlineExceeded { .. } - | Self::WriteZero { .. } => None, - } - } -} - -trait OpeningRequestWriter { - fn set_write_timeout(&self, timeout: Duration) -> io::Result<()>; - fn clear_write_timeout(&self) -> io::Result<()>; - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result; -} - -impl OpeningRequestWriter for TcpStream { - fn set_write_timeout(&self, timeout: Duration) -> io::Result<()> { - TcpStream::set_write_timeout(self, Some(timeout)) - } - - fn clear_write_timeout(&self) -> io::Result<()> { - TcpStream::set_write_timeout(self, None) - } - - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { - self.write(bytes) - } -} - -fn write_request_with_clock( - writer: &mut dyn OpeningRequestWriter, - request: &[u8], - write_timeout: Duration, - now: &mut dyn FnMut() -> Instant, -) -> Result { - let deadline = now() + write_timeout; - let mut bytes_written = 0; - - while bytes_written < request.len() { - let remaining = deadline.saturating_duration_since(now()); - if remaining.is_zero() { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written }, - ); - } - writer.set_write_timeout(remaining).map_err(|source| { - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written, - source, - } - })?; - - match writer.write_request_bytes(&request[bytes_written..]) { - Ok(0) => { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written }); - } - Ok(count) => { - bytes_written += count; - if deadline.saturating_duration_since(now()).is_zero() { - return Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written, - }, - ); - } - } - Err(source) => { - if source.kind() == io::ErrorKind::Interrupted { - continue; - } - if matches!( - source.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ) { - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written, - source, - }); - } - return Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written, - source, - }); - } - } - } - - writer.clear_write_timeout().map_err(|source| { - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written, - source, - } - })?; - - Ok(bytes_written) -} - -#[cfg(test)] -#[allow(clippy::expect_used)] -mod opening_write_tests { - use super::*; - use std::{ - collections::VecDeque, - net::{Shutdown, TcpListener}, - thread, - }; - - use originweave_core::WebDriverBiDiWebSocketEndpoint; - - #[derive(Debug)] - enum WriteAction { - Count(usize), - Error(io::ErrorKind), - } - - #[derive(Debug)] - struct FakeWriter { - timeout_error: Option, - clear_timeout_error: Option, - actions: VecDeque, - } - - impl FakeWriter { - fn new(actions: impl IntoIterator) -> Self { - Self { - timeout_error: None, - clear_timeout_error: None, - actions: actions.into_iter().collect(), - } - } - } - - impl OpeningRequestWriter for FakeWriter { - fn set_write_timeout(&self, _timeout: Duration) -> io::Result<()> { - if let Some(kind) = self.timeout_error { - return Err(io::Error::from(kind)); - } - Ok(()) - } - - fn clear_write_timeout(&self) -> io::Result<()> { - if let Some(kind) = self.clear_timeout_error { - return Err(io::Error::from(kind)); - } - Ok(()) - } - - fn write_request_bytes(&mut self, bytes: &[u8]) -> io::Result { - let action = self - .actions - .pop_front() - .unwrap_or(WriteAction::Count(bytes.len())); - match action { - WriteAction::Count(count) => Ok(count.min(bytes.len())), - WriteAction::Error(kind) => Err(io::Error::from(kind)), - } - } - } - - impl FrameWriter for FakeWriter { - fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { - let error = if timeout.is_some() { - self.timeout_error - } else { - self.clear_timeout_error - }; - error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) - } - - fn write_frame_bytes(&mut self, bytes: &[u8]) -> io::Result { - self.write_request_bytes(bytes) - } - } - - #[derive(Clone, Debug)] - enum ReadAction { - Byte(u8), - Count(usize), - End, - Error(io::ErrorKind), - } - - #[derive(Debug)] - struct FakeReader { - actions: VecDeque, - mode_error: Option, - cleanup_error: Option, - } - - impl FakeReader { - fn new(actions: impl IntoIterator) -> Self { - Self { - actions: actions.into_iter().collect(), - mode_error: None, - cleanup_error: None, - } - } - } - - impl OpeningResponseReader for FakeReader { - fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> { - let error = if nonblocking { - self.mode_error - } else { - self.cleanup_error - }; - error.map_or(Ok(()), |kind| Err(io::Error::from(kind))) - } - - fn read_response_bytes(&mut self, bytes: &mut [u8]) -> io::Result { - match self.actions.pop_front().unwrap_or(ReadAction::End) { - ReadAction::Byte(byte) => { - bytes[0] = byte; - Ok(1) - } - ReadAction::Count(count) => Ok(count), - ReadAction::End => Ok(0), - ReadAction::Error(kind) => Err(io::Error::from(kind)), - } - } - } - - fn client_key() -> WebDriverBiDiWebSocketClientKey { - WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ==") - .expect("test client key must be valid") - } - - fn valid_response() -> Vec { - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec() - } - - fn byte_actions(bytes: &[u8]) -> Vec { - bytes.iter().copied().map(ReadAction::Byte).collect() - } - - fn is_malformed_response(response: &[u8], key: &WebDriverBiDiWebSocketClientKey) -> bool { - matches!( - parse_opening_response(response, key), - Err(WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { .. }) - ) - } - - fn read_with_fake( - reader: &mut FakeReader, - now_values: impl IntoIterator, - ) -> Result<(u16, usize), WebDriverBiDiWebSocketHandshakeResponseError> { - let key = client_key(); - let fallback = Instant::now(); - let mut now_values = now_values.into_iter(); - let mut now = || now_values.next().unwrap_or(fallback); - read_opening_response_with_clock(reader, &key, Duration::from_secs(1), &mut now) - } - - fn read_frame_with_fake( - reader: &mut FakeReader, - now_values: impl IntoIterator, - ) -> Result { - let fallback = Instant::now(); - let mut now_values = now_values.into_iter(); - let mut now = || now_values.next().unwrap_or(fallback); - read_frame_with_clock(reader, Duration::from_secs(1), &mut now) - } - - #[test] - fn parser_accepts_case_insensitive_upgrade_tokens_and_rejects_malformed_headers() { - let key = client_key(); - let response = b"HTTP/1.1 101 Switching Protocols\r\nUpGrAdE: WebSocket\r\nConnection: keep-alive, Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nX-Test: retained\r\n\r\n"; - let parsed = parse_opening_response(response, &key).expect("valid response"); - assert_eq!(parsed.status_code, 101); - assert_eq!(parsed.byte_count, response.len()); - assert!(!is_malformed_response(response, &key)); - let same_length_mismatch = String::from_utf8(response.to_vec()) - .expect("valid response fixture") - .replace( - "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", - "s3pPLMBiTxaQ9kYGzzhZRbK+xOoX", - ); - assert!(parse_opening_response(same_length_mismatch.as_bytes(), &key).is_err()); - - let malformed_responses = [ - b"HTTP/1.1 101".to_vec(), - vec![0xff, b'\r', b'\n', b'\r', b'\n'], - b"HTTP/1.1 101\0 Switching Protocols\r\n\r\n".to_vec(), - b"HTTP/1.1 200 OK\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\n Upgrade: websocket\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nBad Header: value\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\n: value\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: web\x01socket\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nUpgrade: websocket\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nConnection: Upgrade\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nSec-WebSocket-Accept: one\r\nSec-WebSocket-Accept: two\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: h2c\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: keep-alive\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n".to_vec(), - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n".to_vec(), - ]; - for response in malformed_responses { - assert!(is_malformed_response(&response, &key)); - } - } - - #[test] - fn bounded_response_reader_covers_deadlines_size_io_and_cleanup() { - let start = Instant::now(); - - let mut valid_reader = FakeReader::new(byte_actions(&valid_response())); - let valid = read_with_fake(&mut valid_reader, [start]); - assert!(valid.is_ok()); - - let mut malformed_reader = FakeReader::new(byte_actions(b"HTTP/1.1 200 OK\r\n\r\n")); - assert!(read_with_fake(&mut malformed_reader, [start]).is_err()); - - let mut interrupted_reader = FakeReader::new( - std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) - .chain(byte_actions(&valid_response())), - ); - assert!(read_with_fake(&mut interrupted_reader, [start]).is_ok()); - - let mut mode_error_reader = FakeReader::new([]); - mode_error_reader.mode_error = Some(io::ErrorKind::InvalidInput); - assert!(read_with_fake(&mut mode_error_reader, [start]).is_err()); - - let mut ended_reader = FakeReader::new([ReadAction::End]); - assert!(read_with_fake(&mut ended_reader, [start]).is_err()); - - let mut count_reader = FakeReader::new([ReadAction::Count(2)]); - assert!(read_with_fake(&mut count_reader, [start]).is_err()); - - let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); - assert!(read_with_fake(&mut failed_reader, [start]).is_err()); - - let mut retrying_reader = FakeReader::new( - std::iter::once(ReadAction::Error(io::ErrorKind::WouldBlock)) - .chain(byte_actions(&valid_response())), - ); - assert!(read_with_fake(&mut retrying_reader, [start]).is_ok()); - - let mut timed_out_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::TimedOut)]); - assert!( - read_with_fake( - &mut timed_out_reader, - [start, start, start + Duration::from_secs(1)] - ) - .is_err() - ); - - let mut deadline_reader = FakeReader::new([ReadAction::End]); - assert!( - read_with_fake( - &mut deadline_reader, - [start, start + Duration::from_secs(1)] - ) - .is_err() - ); - - let mut late_response_reader = FakeReader::new(byte_actions(&valid_response())); - let mut late_response_times = vec![start; valid_response().len() + 1]; - late_response_times.push(start + Duration::from_secs(1)); - assert!(read_with_fake(&mut late_response_reader, late_response_times).is_err()); - - let mut cleanup_reader = FakeReader::new(byte_actions(&valid_response())); - cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); - assert!(read_with_fake(&mut cleanup_reader, [start]).is_err()); - - let mut too_large_reader = FakeReader::new(std::iter::repeat_n( - ReadAction::Byte(b'a'), - MAX_WEBSOCKET_OPENING_RESPONSE_BYTES, - )); - assert!(read_with_fake(&mut too_large_reader, [start]).is_err()); - } - - #[test] - fn response_errors_have_deterministic_messages_and_sources() { - let source = io::Error::from(io::ErrorKind::InvalidInput); - let errors = [ - WebDriverBiDiWebSocketHandshakeResponseError::InvalidResponseTimeout { - response_timeout: Duration::ZERO, - maximum_timeout: MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseDeadlineExceeded { - bytes_read: 1, - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseTooLarge { - bytes_read: 1, - maximum_bytes: 1, - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadModeConfigurationFailed { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadTimedOut { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseReadFailed { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }, - WebDriverBiDiWebSocketHandshakeResponseError::ResponseEndedBeforeHeaders { - bytes_read: 1, - }, - WebDriverBiDiWebSocketHandshakeResponseError::MalformedResponse { reason: "test" }, - WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch, - WebDriverBiDiWebSocketHandshakeResponseError::ReadModeCleanupFailed { source }, - ]; - for (error, has_source) in errors.iter().zip([ - false, false, false, true, true, true, false, false, false, true, - ]) { - assert!(!error.to_string().is_empty()); - assert_eq!(error.source().is_some(), has_source); - } - } - - #[test] - fn bounded_writer_completes_partial_and_interrupted_writes() { - let mut writer = FakeWriter::new([ - WriteAction::Count(2), - WriteAction::Error(io::ErrorKind::Interrupted), - WriteAction::Count(3), - ]); - let start = Instant::now(); - let mut times = VecDeque::from([start, start, start, start]); - let mut now = || times.pop_front().unwrap_or(start); - let result = - write_request_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now); - let is_five = |candidate: Result| { - matches!(candidate, Ok(5)) - }; - assert!(is_five(result)); - assert!(!is_five(Ok(4))); - } - - fn join_loopback_server(server: thread::JoinHandle>) -> bool { - match server.join() { - Ok(result) => { - result.expect("loopback server must accept the client"); - false - } - Err(_) => true, - } - } - - #[test] - fn bounded_writer_clears_real_socket_timeout_before_success() { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); - let address = listener - .local_addr() - .expect("test listener address must be available"); - let server = thread::spawn(move || listener.accept().map(|_| ())); - let mut stream = TcpStream::connect(address).expect("test client must connect"); - let start = Instant::now(); - let mut now = || start; - - let request_byte_count = - write_request_with_clock(&mut stream, b"opening", Duration::from_secs(1), &mut now) - .expect("the opening request must be written"); - - assert_eq!(request_byte_count, 7); - assert_eq!( - stream - .write_timeout() - .expect("the socket timeout must be inspectable"), - None - ); - assert!(!join_loopback_server(server)); - } - - #[test] - fn panicked_loopback_server_is_reported() { - let server = thread::spawn(|| -> io::Result<()> { - std::panic::resume_unwind(Box::new("intentional test-only server panic")); - }); - - assert!(join_loopback_server(server)); - } - - #[test] - fn bounded_writer_rejects_cleanup_failure_without_success_handoff() { - let mut writer = FakeWriter::new([WriteAction::Count(1)]); - writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); - let start = Instant::now(); - let mut now = || start; - - let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_cleanup_failure = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - .. - } - ) - ) - }; - assert!(is_cleanup_failure(result)); - assert!(!is_cleanup_failure(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } - ))); - } - - #[test] - fn bounded_writer_rejects_completion_observed_after_total_deadline() { - let mut writer = FakeWriter::new([WriteAction::Count(1)]); - let start = Instant::now(); - let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); - let mut now = || times.pop_front().unwrap_or(start + Duration::from_secs(1)); - let result = write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_deadline_after_one = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 1 - } - ) - ) - }; - assert!(is_deadline_after_one(result)); - assert!(!is_deadline_after_one(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 } - ))); - } - - #[test] - fn bounded_writer_classifies_deadline_timeout_zero_and_io_failures() { - let start = Instant::now(); - - let mut deadline_writer = FakeWriter::new([]); - let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); - let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); - let deadline = write_request_with_clock( - &mut deadline_writer, - b"x", - Duration::from_secs(1), - &mut deadline_now, - ); - let is_deadline_before_write = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { - bytes_written: 0 - } - ) - ) - }; - assert!(is_deadline_before_write(deadline)); - assert!(!is_deadline_before_write(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - - let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); - let mut zero_now = || start; - let zero = write_request_with_clock( - &mut zero_writer, - b"x", - Duration::from_secs(1), - &mut zero_now, - ); - let is_zero_write = |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 }) - ) - }; - assert!(is_zero_write(zero)); - assert!(!is_zero_write(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 0 } - ))); - - for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { - let mut writer = FakeWriter::new([WriteAction::Error(kind)]); - let mut now = || start; - let timed_out = - write_request_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now); - let is_timed_out = - |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written: 0, - .. - }) - ) - }; - assert!(is_timed_out(timed_out)); - assert!(!is_timed_out(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - source: io::Error::from(kind), - } - ))); - } - - let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - let mut failed_now = || start; - let failed = write_request_with_clock( - &mut failed_writer, - b"x", - Duration::from_secs(1), - &mut failed_now, - ); - let is_failed = |candidate: Result| { - matches!( - candidate, - Err(WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 0, - .. - }) - ) - }; - assert!(is_failed(failed)); - assert!(!is_failed(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - - let mut configuration_writer = FakeWriter::new([]); - configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); - let mut configuration_now = || start; - let configuration = write_request_with_clock( - &mut configuration_writer, - b"x", - Duration::from_secs(1), - &mut configuration_now, - ); - let is_configuration_failure = - |candidate: Result| { - matches!( - candidate, - Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 0, - .. - } - ) - ) - }; - assert!(is_configuration_failure(configuration)); - assert!(!is_configuration_failure(Err( - WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 0 } - ))); - } - - #[test] - fn opening_write_errors_have_deterministic_messages_and_sources() { - let invalid = WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { - write_timeout: Duration::ZERO, - maximum_timeout: MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, - }; - let deadline = - WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 1 }; - let configure = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }; - let timed_out = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }; - let zero = WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 1 }; - let failed = WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }; - let cleanup = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }; - - assert!(!invalid.to_string().is_empty()); - assert!(!deadline.to_string().is_empty()); - assert!(!configure.to_string().is_empty()); - assert!(!timed_out.to_string().is_empty()); - assert!(!zero.to_string().is_empty()); - assert!(!failed.to_string().is_empty()); - assert!(!cleanup.to_string().is_empty()); - assert!(invalid.source().is_none()); - assert!(deadline.source().is_none()); - assert!(configure.source().is_some()); - assert!(timed_out.source().is_some()); - assert!(zero.source().is_none()); - assert!(failed.source().is_some()); - assert!(cleanup.source().is_some()); - } - - #[test] - fn frame_codec_reader_writer_and_errors_are_fully_bounded() { - let masking_key = WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]); - assert_eq!(masking_key.as_bytes(), &[0x37, 0xfa, 0x21, 0x3d]); - for payload in [vec![b'x'; 125], vec![b'x'; 126], vec![b'x'; 65_536]] { - let frame = serialize_text_frame(&payload, masking_key); - assert_eq!(frame[0], 0x81); - assert_ne!(frame[1] & 0x80, 0); - let mask_offset = match payload.len() { - 0..=125 => 2, - 126..=65_535 => 4, - _ => 10, - }; - assert_eq!(&frame[mask_offset..mask_offset + 4], masking_key.as_bytes()); - } - - let start = Instant::now(); - let valid = [0x81, 0x01, b'x']; - let mut valid_reader = FakeReader::new(byte_actions(&valid)); - let valid_frame = read_frame_with_fake(&mut valid_reader, [start]).expect("valid frame"); - assert!(valid_frame.fin()); - assert_eq!(valid_frame.opcode(), 0x1); - assert_eq!(valid_frame.payload(), b"x"); - - let mut ping_reader = FakeReader::new([ReadAction::Byte(0x89), ReadAction::Byte(0)]); - let ping = read_frame_with_fake(&mut ping_reader, [start]).expect("ping frame"); - assert!(ping.fin()); - assert_eq!(ping.opcode(), 0x9); - - let mut continuation_reader = - FakeReader::new([ReadAction::Byte(0x00), ReadAction::Byte(0)]); - let continuation = - read_frame_with_fake(&mut continuation_reader, [start]).expect("continuation frame"); - assert!(!continuation.fin()); - assert_eq!(continuation.opcode(), 0); - - let mut extended_16 = FakeReader::new( - byte_actions(&[0x81, 126, 0, 126]) - .into_iter() - .chain([ReadAction::Count(126)]), - ); - assert_eq!( - read_frame_with_fake(&mut extended_16, [start]) - .expect("extended frame") - .payload() - .len(), - 126 - ); - let mut extended_64 = FakeReader::new( - byte_actions(&[0x81, 127, 0, 0, 0, 0, 0, 1, 0, 0]) - .into_iter() - .chain([ReadAction::Count(65_536)]), - ); - assert_eq!( - read_frame_with_fake(&mut extended_64, [start]) - .expect("large extended frame") - .payload() - .len(), - 65_536 - ); - let mut extended_16_error = FakeReader::new([ - ReadAction::Byte(0x81), - ReadAction::Byte(126), - ReadAction::Error(io::ErrorKind::BrokenPipe), - ]); - assert!(read_frame_with_fake(&mut extended_16_error, [start]).is_err()); - let mut extended_64_error = FakeReader::new([ - ReadAction::Byte(0x81), - ReadAction::Byte(127), - ReadAction::Error(io::ErrorKind::BrokenPipe), - ]); - assert!(read_frame_with_fake(&mut extended_64_error, [start]).is_err()); - - let mut oversized_header = vec![0x81, 127]; - oversized_header - .extend_from_slice(&((MAX_WEBSOCKET_FRAME_PAYLOAD_BYTES as u64) + 1).to_be_bytes()); - let mut malformed_readers = vec![ - vec![0xc1, 0], - vec![0x09, 0], - vec![0x83, 0], - vec![0x81, 0x80], - vec![0x81, 126, 0, 1], - vec![0x81, 127, 0x80, 0, 0, 0, 0, 0, 0, 0], - vec![0x81, 127, 0, 0, 0, 0, 0, 0, 0xff, 0xff], - vec![0x89, 126, 0, 126], - oversized_header, - ]; - for bytes in malformed_readers.drain(..) { - let mut reader = FakeReader::new(byte_actions(&bytes)); - assert!(read_frame_with_fake(&mut reader, [start]).is_err()); - } - let mut count_reader = FakeReader::new([ReadAction::Count(3)]); - assert!(read_frame_with_fake(&mut count_reader, [start]).is_err()); - let mut ended_reader = FakeReader::new([ReadAction::Byte(0x81), ReadAction::End]); - assert!(read_frame_with_fake(&mut ended_reader, [start]).is_err()); - let mut interrupted_reader = FakeReader::new( - std::iter::once(ReadAction::Error(io::ErrorKind::Interrupted)) - .chain(byte_actions(&valid)), - ); - assert!(read_frame_with_fake(&mut interrupted_reader, [start]).is_ok()); - for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { - let mut retrying_reader = FakeReader::new( - std::iter::once(ReadAction::Error(kind)).chain(byte_actions(&valid)), - ); - assert!(read_frame_with_fake(&mut retrying_reader, [start]).is_ok()); - } - let mut payload_error_reader = FakeReader::new([ - ReadAction::Byte(0x81), - ReadAction::Byte(1), - ReadAction::Error(io::ErrorKind::BrokenPipe), - ]); - assert!(read_frame_with_fake(&mut payload_error_reader, [start]).is_err()); - let mut failed_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::BrokenPipe)]); - assert!(read_frame_with_fake(&mut failed_reader, [start]).is_err()); - let mut mode_reader = FakeReader::new([]); - mode_reader.mode_error = Some(io::ErrorKind::InvalidInput); - assert!(read_frame_with_fake(&mut mode_reader, [start]).is_err()); - let mut timeout_reader = FakeReader::new([ReadAction::Error(io::ErrorKind::WouldBlock)]); - assert!( - read_frame_with_fake( - &mut timeout_reader, - [start, start, start + Duration::from_secs(1)] - ) - .is_err() - ); - let mut deadline_reader = FakeReader::new([]); - assert!( - read_frame_with_fake( - &mut deadline_reader, - [start, start + Duration::from_secs(1)] - ) - .is_err() - ); - let mut cleanup_reader = FakeReader::new(byte_actions(&valid)); - cleanup_reader.cleanup_error = Some(io::ErrorKind::InvalidInput); - assert!(read_frame_with_fake(&mut cleanup_reader, [start]).is_err()); - - let mut writer = FakeWriter::new([ - WriteAction::Count(1), - WriteAction::Error(io::ErrorKind::Interrupted), - WriteAction::Count(99), - ]); - let mut now = || start; - assert_eq!( - write_frame_with_clock(&mut writer, b"hello", Duration::from_secs(1), &mut now) - .expect("frame write"), - 5 - ); - let mut empty_writer = FakeWriter::new([]); - let mut empty_now = || start; - assert_eq!( - write_frame_with_clock( - &mut empty_writer, - b"", - Duration::from_secs(1), - &mut empty_now - ) - .expect("empty frame write"), - 0 - ); - let mut deadline_writer = FakeWriter::new([]); - let mut deadline_times = VecDeque::from([start, start + Duration::from_secs(1)]); - let mut deadline_now = || deadline_times.pop_front().unwrap_or(start); - assert!( - write_frame_with_clock( - &mut deadline_writer, - b"x", - Duration::from_secs(1), - &mut deadline_now - ) - .is_err() - ); - let mut zero_writer = FakeWriter::new([WriteAction::Count(0)]); - let mut zero_now = || start; - assert!( - write_frame_with_clock( - &mut zero_writer, - b"x", - Duration::from_secs(1), - &mut zero_now - ) - .is_err() - ); - for kind in [io::ErrorKind::TimedOut, io::ErrorKind::WouldBlock] { - let mut writer = FakeWriter::new([WriteAction::Error(kind)]); - let mut times = VecDeque::from([start, start, start + Duration::from_secs(1)]); - let mut now = || times.pop_front().unwrap_or(start); - assert!( - write_frame_with_clock(&mut writer, b"x", Duration::from_secs(1), &mut now) - .is_err() - ); - } - let mut retrying_writer = FakeWriter::new([ - WriteAction::Error(io::ErrorKind::WouldBlock), - WriteAction::Count(1), - ]); - let mut retrying_now = || start; - assert_eq!( - write_frame_with_clock( - &mut retrying_writer, - b"x", - Duration::from_secs(1), - &mut retrying_now - ) - .expect("retrying frame write"), - 1 - ); - let mut interrupted_writer = FakeWriter::new([ - WriteAction::Error(io::ErrorKind::Interrupted), - WriteAction::Count(1), - ]); - let mut interrupted_now = || start; - assert_eq!( - write_frame_with_clock( - &mut interrupted_writer, - b"x", - Duration::from_secs(1), - &mut interrupted_now - ) - .expect("interrupted frame write"), - 1 - ); - let mut failed_writer = FakeWriter::new([WriteAction::Error(io::ErrorKind::BrokenPipe)]); - let mut failed_now = || start; - assert!( - write_frame_with_clock( - &mut failed_writer, - b"x", - Duration::from_secs(1), - &mut failed_now - ) - .is_err() - ); - let mut configuration_writer = FakeWriter::new([]); - configuration_writer.timeout_error = Some(io::ErrorKind::InvalidInput); - let mut configuration_now = || start; - assert!( - write_frame_with_clock( - &mut configuration_writer, - b"x", - Duration::from_secs(1), - &mut configuration_now - ) - .is_err() - ); - let mut cleanup_writer = FakeWriter::new([WriteAction::Count(1)]); - cleanup_writer.clear_timeout_error = Some(io::ErrorKind::InvalidInput); - let mut cleanup_now = || start; - assert!( - write_frame_with_clock( - &mut cleanup_writer, - b"x", - Duration::from_secs(1), - &mut cleanup_now - ) - .is_err() - ); - - for timeout in [ - Duration::ZERO, - MAX_WEBSOCKET_FRAME_TIMEOUT + Duration::from_nanos(1), - ] { - assert!(validate_frame_timeout(timeout).is_err()); - } - let errors = [ - WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { - frame_timeout: Duration::ZERO, - maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, - }, - WebDriverBiDiWebSocketFrameError::FrameTooLarge { - payload_bytes: 2, - maximum_bytes: 1, - }, - WebDriverBiDiWebSocketFrameError::FrameReadModeConfigurationFailed { - source: io::Error::from(io::ErrorKind::InvalidInput), - }, - WebDriverBiDiWebSocketFrameError::FrameReadTimedOut { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }, - WebDriverBiDiWebSocketFrameError::FrameReadFailed { - bytes_read: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }, - WebDriverBiDiWebSocketFrameError::FrameEnded { bytes_read: 1 }, - WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "test" }, - WebDriverBiDiWebSocketFrameError::FrameWriteModeConfigurationFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::InvalidInput), - }, - WebDriverBiDiWebSocketFrameError::FrameWriteTimedOut { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::TimedOut), - }, - WebDriverBiDiWebSocketFrameError::FrameWriteFailed { - bytes_written: 1, - source: io::Error::from(io::ErrorKind::BrokenPipe), - }, - WebDriverBiDiWebSocketFrameError::FrameWriteZero { bytes_written: 1 }, - WebDriverBiDiWebSocketFrameError::FrameWriteCleanupFailed { - source: io::Error::from(io::ErrorKind::InvalidInput), - }, - ]; - for (error, has_source) in errors.iter().zip([ - false, false, true, true, true, false, false, true, true, true, false, true, - ]) { - assert!(!error.to_string().is_empty()); - assert_eq!(error.source().is_some(), has_source); - } - } - - #[test] - fn established_frame_write_discards_locally_revoked_streams() { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); - let address = listener - .local_addr() - .expect("test listener address must be available"); - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("test server must accept"); - stream - .write_all(&valid_response()) - .expect("test server must write response"); - }); - - let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( - "ws://{address}/session/01234567-89ab-cdef-0123-456789abcdef" - )) - .expect("test endpoint must be valid"); - let correlated = endpoint - .correlate_session_id("01234567-89ab-cdef-0123-456789abcdef") - .expect("test session must correlate"); - let target = correlated - .into_explicit_connect_target() - .expect("test target must be explicit"); - let connection = - crate::WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) - .expect("test connection plan must be valid") - .connect() - .expect("test connection must succeed"); - let sent = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key()) - .expect("test handshake plan must be valid") - .write_opening_request(Duration::from_secs(1)) - .expect("test opening request must be written"); - let established = sent - .read_opening_response(Duration::from_secs(1)) - .expect("test opening response must be valid"); - let _ = established.stream.shutdown(Shutdown::Both); - assert!( - established - .write_text_frame( - "x", - WebDriverBiDiWebSocketMaskKey::new([0x37, 0xfa, 0x21, 0x3d]), - Duration::from_secs(1), - ) - .is_err() - ); - assert!(server.join().is_ok()); - } -} diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs new file mode 100644 index 000000000..8d35740d4 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -0,0 +1,211 @@ +//! Validated public WebDriver BiDi WebSocket state wrappers. +//! +//! The underlying transport remains responsible for exact-stream I/O. These wrappers preserve the +//! public state machine while adding protocol validation that must run before a received frame is +//! released to callers. + +use std::{fmt, time::Duration}; + +use originweave_core::VerifiedWebDriverBiDiSocketPeer; + +use crate::{ + WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence, + webdriver_bidi_websocket_handshake as raw, +}; + +/// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. +pub struct WebDriverBiDiWebSocketHandshakePlan(raw::WebDriverBiDiWebSocketHandshakePlan); + +impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl WebDriverBiDiWebSocketHandshakePlan { + /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. + pub fn new( + connection: WebDriverBiDiTcpConnection, + client_key: raw::WebDriverBiDiWebSocketClientKey, + ) -> Result { + raw::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key).map(Self) + } + + /// Borrow the exact serialized RFC 6455 opening-request bytes. + #[must_use] + pub fn request_bytes(&self) -> &[u8] { + self.0.request_bytes() + } + + /// Borrow the exact client key that a later server-handshake validator must correlate. + #[must_use] + pub const fn client_key(&self) -> &raw::WebDriverBiDiWebSocketClientKey { + self.0.client_key() + } + + /// Borrow the exact peer/session evidence already verified before request construction. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + self.0.verified_peer() + } + + /// Write the complete bounded opening request on the exact verified stream within one deadline. + pub fn write_opening_request( + self, + write_timeout: Duration, + ) -> Result + { + self.0 + .write_opening_request(write_timeout) + .map(WebDriverBiDiWebSocketOpeningRequestSent) + } +} + +/// A live verified stream after the complete client WebSocket opening request has been written. +pub struct WebDriverBiDiWebSocketOpeningRequestSent(raw::WebDriverBiDiWebSocketOpeningRequestSent); + +impl fmt::Debug for WebDriverBiDiWebSocketOpeningRequestSent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl WebDriverBiDiWebSocketOpeningRequestSent { + /// Borrow the exact verified transport evidence retained with this live stream. + #[must_use] + pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { + self.0.transport_evidence() + } + + /// Borrow the exact client key required to validate the later server accept value. + #[must_use] + pub const fn client_key(&self) -> &raw::WebDriverBiDiWebSocketClientKey { + self.0.client_key() + } + + /// Return the exact number of opening-request bytes written before success was emitted. + #[must_use] + pub const fn request_byte_count(&self) -> usize { + self.0.request_byte_count() + } + + /// Return the total write deadline configured for this opening request. + #[must_use] + pub const fn write_timeout(&self) -> Duration { + self.0.write_timeout() + } + + /// Read and validate the bounded RFC 6455 server opening response on this exact stream. + pub fn read_opening_response( + self, + response_timeout: Duration, + ) -> Result + { + self.0 + .read_opening_response(response_timeout) + .map(WebDriverBiDiWebSocketEstablished) + } +} + +/// A live verified stream after both RFC 6455 opening messages were validated. +pub struct WebDriverBiDiWebSocketEstablished(raw::WebDriverBiDiWebSocketEstablished); + +impl fmt::Debug for WebDriverBiDiWebSocketEstablished { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl WebDriverBiDiWebSocketEstablished { + /// Borrow the exact verified transport evidence retained with this live stream. + #[must_use] + pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { + self.0.transport_evidence() + } + + /// Borrow the exact client key correlated with the validated server accept value. + #[must_use] + pub const fn client_key(&self) -> &raw::WebDriverBiDiWebSocketClientKey { + self.0.client_key() + } + + /// Return the validated HTTP status code, currently always `101` on success. + #[must_use] + pub const fn response_status(&self) -> u16 { + self.0.response_status() + } + + /// Return the number of HTTP opening-response bytes consumed through its header terminator. + #[must_use] + pub const fn response_byte_count(&self) -> usize { + self.0.response_byte_count() + } + + /// Return the total response deadline configured for this opening response. + #[must_use] + pub const fn response_timeout(&self) -> Duration { + self.0.response_timeout() + } + + /// Return the number of request bytes written before the response was read. + #[must_use] + pub const fn request_byte_count(&self) -> usize { + self.0.request_byte_count() + } + + /// Return the total write deadline configured for the preceding opening request. + #[must_use] + pub const fn write_timeout(&self) -> Duration { + self.0.write_timeout() + } + + /// Write one unfragmented, masked UTF-8 text frame on this verified stream. + pub fn write_text_frame( + self, + text: &str, + masking_key: raw::WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result { + self.0 + .write_text_frame(text, masking_key, frame_timeout) + .map(Self) + } + + /// Write one final masked RFC 6455 Pong control frame on this verified stream. + pub fn write_pong_frame( + self, + payload: &[u8], + masking_key: raw::WebDriverBiDiWebSocketMaskKey, + frame_timeout: Duration, + ) -> Result { + self.0 + .write_pong_frame(payload, masking_key, frame_timeout) + .map(Self) + } + + /// Read one bounded RFC 6455 frame and reject close status codes forbidden on the wire. + pub fn read_frame( + self, + frame_timeout: Duration, + ) -> Result<(Self, raw::WebDriverBiDiWebSocketFrame), raw::WebDriverBiDiWebSocketFrameError> { + let (established, frame) = self.0.read_frame(frame_timeout)?; + validate_close_status_code(&frame)?; + Ok((Self(established), frame)) + } +} + +fn validate_close_status_code( + frame: &raw::WebDriverBiDiWebSocketFrame, +) -> Result<(), raw::WebDriverBiDiWebSocketFrameError> { + if frame.opcode() != 0x8 || frame.payload().len() < 2 { + return Ok(()); + } + + let status_code = u16::from_be_bytes([frame.payload()[0], frame.payload()[1]]); + if !(1000..=4999).contains(&status_code) || matches!(status_code, 1005 | 1006 | 1015) { + return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: "Close frame status code is not valid on the wire", + }); + } + Ok(()) +} From d6231ce3eafb6480af71e886b780d726fec5f272 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:13:28 -0700 Subject: [PATCH 298/570] style(network): apply canonical Rust formatting --- .../src/webdriver_bidi_websocket_control.rs | 3 +-- .../src/webdriver_bidi_websocket_validated.rs | 9 ++++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs index 4bd645d66..e76919253 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -6,8 +6,7 @@ use std::{ }; use crate::{ - MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError, - WebDriverBiDiWebSocketMaskKey, + MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, webdriver_bidi_websocket_handshake::WebDriverBiDiWebSocketEstablished, }; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index 8d35740d4..f36459d88 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -53,8 +53,10 @@ impl WebDriverBiDiWebSocketHandshakePlan { pub fn write_opening_request( self, write_timeout: Duration, - ) -> Result - { + ) -> Result< + WebDriverBiDiWebSocketOpeningRequestSent, + raw::WebDriverBiDiWebSocketOpeningWriteError, + > { self.0 .write_opening_request(write_timeout) .map(WebDriverBiDiWebSocketOpeningRequestSent) @@ -187,7 +189,8 @@ impl WebDriverBiDiWebSocketEstablished { pub fn read_frame( self, frame_timeout: Duration, - ) -> Result<(Self, raw::WebDriverBiDiWebSocketFrame), raw::WebDriverBiDiWebSocketFrameError> { + ) -> Result<(Self, raw::WebDriverBiDiWebSocketFrame), raw::WebDriverBiDiWebSocketFrameError> + { let (established, frame) = self.0.read_frame(frame_timeout)?; validate_close_status_code(&frame)?; Ok((Self(established), frame)) From bc280a272e916540b48f07da4dcb1ec44dd49470 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:16:14 -0700 Subject: [PATCH 299/570] fix(network): route public handshake through validated state --- crates/originweave-network/src/lib.rs | 12 +++++++----- .../src/webdriver_bidi_websocket_control.rs | 2 +- .../src/webdriver_bidi_websocket_validated.rs | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 87d909823..e969d3401 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -19,8 +19,10 @@ mod webdriver_bidi_websocket_control; #[cfg(test)] #[allow(clippy::expect_used)] mod webdriver_bidi_websocket_coverage_tests; +#[path = "webdriver_bidi_websocket_validated.rs"] mod webdriver_bidi_websocket_handshake; -mod webdriver_bidi_websocket_validated; +#[path = "webdriver_bidi_websocket_handshake.rs"] +mod webdriver_bidi_websocket_handshake_raw; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, @@ -31,6 +33,10 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; pub use webdriver_bidi_websocket_handshake::{ + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketOpeningRequestSent, +}; +pub use webdriver_bidi_websocket_handshake_raw::{ MAX_WEBSOCKET_FRAME_PAYLOAD_SIZE, MAX_WEBSOCKET_FRAME_TIMEOUT, MAX_WEBSOCKET_OPENING_RESPONSE_SIZE, MAX_WEBSOCKET_OPENING_RESPONSE_TIMEOUT, MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, @@ -38,7 +44,3 @@ pub use webdriver_bidi_websocket_handshake::{ WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningWriteError, }; -pub use webdriver_bidi_websocket_validated::{ - WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketOpeningRequestSent, -}; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs index e76919253..d9f7b42af 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_control.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_control.rs @@ -7,7 +7,7 @@ use std::{ use crate::{ MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, - webdriver_bidi_websocket_handshake::WebDriverBiDiWebSocketEstablished, + webdriver_bidi_websocket_handshake_raw::WebDriverBiDiWebSocketEstablished, }; const MAX_WEBSOCKET_CONTROL_FRAME_PAYLOAD_BYTES: usize = 125; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index f36459d88..dc537d0f5 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -10,7 +10,7 @@ use originweave_core::VerifiedWebDriverBiDiSocketPeer; use crate::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence, - webdriver_bidi_websocket_handshake as raw, + webdriver_bidi_websocket_handshake_raw as raw, }; /// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. From 4b61fceb463386840c9a795af6350298d4c659eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:07:35 -0700 Subject: [PATCH 300/570] docs(bap): index task lifecycle ADR --- docs/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/README.md b/docs/README.md index 03b573c54..ec7c5702e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -86,4 +86,10 @@ Proposed ADRs are reviewable architecture memory, not shipped behavior and not a The second group exists only on this documentation branch until the branch integrates. After integration, the heading remains useful historical provenance; it does not promote either ADR from Proposed to Accepted and it does not claim that the described runtime capability is implemented. +### Proposed decisions introduced by active feature work + +- [ADR 0016: BAP task lifecycle and state authority](adr/0016-bap-task-lifecycle-authority.md) + +ADR 0016 is owned by this active BAP lifecycle feature branch and remains Proposed. Its presence here makes the branch documentation graph complete without presenting the decision or implementation as protected-main truth before integration. + See the [ADR index](adr/README.md) for status rules, required decision structure, supersession rules, and active feature ADRs. The index and each ADR's own status metadata must agree; a PR body, chat transcript, automation prompt, or stale issue reference cannot change ADR status. From f2649a9fc9904330954e48f71515c5efbe09e321 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:08:05 -0700 Subject: [PATCH 301/570] docs(bap): register lifecycle ADR in canonical index --- docs/adr/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/adr/README.md b/docs/adr/README.md index 416231b1c..f9d2dfa0e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,6 +57,14 @@ Proposed ADR files are reviewable target architecture without becoming Accepted ADR 0013 and ADR 0014 exist only on this documentation branch until it integrates. After integration, this subsection remains historical provenance rather than an active-PR claim; both decisions remain Proposed until a later policy-compliant change explicitly changes their lifecycle. +### Proposed decisions introduced by active feature work + +| ADR | Decision | Status | Governs | +|---|---|---|---| +| [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 | + +ADR 0016 belongs to the active BAP lifecycle feature branch. Indexing it makes the branch documentation graph complete while preserving its Proposed lifecycle and active-PR, non-protected-main 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. ## Index completeness rule From 2496a6611c72b61049f5eb8e0df65a4a316e6dd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:33:38 -0700 Subject: [PATCH 302/570] test(network): require WebSocket plan debug redaction --- .../webdriver_bidi_websocket_debug_tests.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs new file mode 100644 index 000000000..9a1571cd8 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs @@ -0,0 +1,52 @@ +use std::{net::TcpListener, thread, time::Duration}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; + +use crate::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const CLIENT_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +#[test] +fn handshake_plan_debug_redacts_client_nonce() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let server = thread::spawn(move || { + listener + .accept() + .map(|_| ()) + .expect("test loopback connection must be accepted"); + }); + + let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://{address}/session/{SESSION_ID}" + )) + .expect("test endpoint must be valid"); + let correlated = endpoint + .correlate_session_id(SESSION_ID) + .expect("test session must correlate"); + let target = correlated + .into_explicit_connect_target() + .expect("test target must be explicit"); + let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("test connection plan must be valid") + .connect() + .expect("test connection must succeed"); + let client_key = WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY) + .expect("test client key must be valid"); + let handshake = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key) + .expect("test handshake plan must be valid"); + + let debug = format!("{handshake:?}"); + assert!(debug.contains("WebDriverBiDiWebSocketHandshakePlan")); + assert!(debug.contains("")); + assert!(!debug.contains(CLIENT_KEY)); + + drop(handshake); + server.join().expect("test server must not panic"); +} From 7ee53f3d0d505d07a61065e9cd6fd4d0a3abc490 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:34:00 -0700 Subject: [PATCH 303/570] test(network): exercise WebSocket plan debug boundary --- crates/originweave-network/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index e969d3401..c3aa6cce8 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -19,6 +19,9 @@ mod webdriver_bidi_websocket_control; #[cfg(test)] #[allow(clippy::expect_used)] mod webdriver_bidi_websocket_coverage_tests; +#[cfg(test)] +#[allow(clippy::expect_used)] +mod webdriver_bidi_websocket_debug_tests; #[path = "webdriver_bidi_websocket_validated.rs"] mod webdriver_bidi_websocket_handshake; #[path = "webdriver_bidi_websocket_handshake.rs"] From 3420aa47e00f0d44d17e2b096da1f05cb28f3e6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 02:35:18 -0700 Subject: [PATCH 304/570] test(network): format WebSocket debug regression --- .../src/webdriver_bidi_websocket_debug_tests.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs index 9a1571cd8..61cba1ca9 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs @@ -23,10 +23,9 @@ fn handshake_plan_debug_redacts_client_nonce() { .expect("test loopback connection must be accepted"); }); - let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( - "ws://{address}/session/{SESSION_ID}" - )) - .expect("test endpoint must be valid"); + let endpoint = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}")) + .expect("test endpoint must be valid"); let correlated = endpoint .correlate_session_id(SESSION_ID) .expect("test session must correlate"); @@ -37,8 +36,8 @@ fn handshake_plan_debug_redacts_client_nonce() { .expect("test connection plan must be valid") .connect() .expect("test connection must succeed"); - let client_key = WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY) - .expect("test client key must be valid"); + let client_key = + WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY).expect("test client key must be valid"); let handshake = WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key) .expect("test handshake plan must be valid"); From 65477acbeeee5a1310e3005d0e827dd2bfce8e1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:03:06 -0700 Subject: [PATCH 305/570] fix(network): redact WebSocket client nonce in debug output --- .../src/webdriver_bidi_websocket_validated.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index dc537d0f5..d32eab98d 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -18,7 +18,12 @@ pub struct WebDriverBiDiWebSocketHandshakePlan(raw::WebDriverBiDiWebSocketHandsh impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(formatter) + formatter + .debug_struct("WebDriverBiDiWebSocketHandshakePlan") + .field("verified_peer", self.0.verified_peer()) + .field("client_key", &"") + .field("request_byte_count", &self.0.request_bytes().len()) + .finish() } } From fb049c8a85710630599f7bf0d5f1ad37fb34487e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:24:17 -0700 Subject: [PATCH 306/570] test(evidence): cover shared RFC 3986 network-path rejection --- crates/originweave-evidence/tests/evidence.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/originweave-evidence/tests/evidence.rs b/crates/originweave-evidence/tests/evidence.rs index 11c3edd23..48d49cbc4 100644 --- a/crates/originweave-evidence/tests/evidence.rs +++ b/crates/originweave-evidence/tests/evidence.rs @@ -80,6 +80,9 @@ fn network_evidence_rejects_non_path_inputs() { "/bad\npath", "/bad path", "/windows\\path", + "/[segment]", + "/raw|pipe", + "/raw-한글", ] { assert_eq!( NetworkEvidence::capture( From 9ffe549de2188a48e04563bb9bc92017a9b9d519 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:13:34 -0700 Subject: [PATCH 307/570] test(network): carry WebSocket nonce redaction into opening-write stack --- .../webdriver_bidi_websocket_handshake.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index 64c1bba6e..be447fd90 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -13,6 +13,7 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REDACTED_CLIENT_KEY: &str = ""; fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); @@ -43,6 +44,57 @@ fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { connection } +#[test] +fn client_key_debug_redacts_websocket_nonce() { + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + + let debug = format!("{key:?}"); + assert!(debug.contains(REDACTED_CLIENT_KEY)); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); +} + +#[test] +fn handshake_plan_debug_redacts_websocket_nonce() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let debug = format!("{plan:?}"); + assert!(debug.contains(REDACTED_CLIENT_KEY)); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + #[test] fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { let listener = TcpListener::bind(("127.0.0.1", 0)); From 117aa45f0c0d95c7d9f314cfa7f450f1cd6fef89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:16:29 -0700 Subject: [PATCH 308/570] fix(network): redact WebSocket nonce debug in opening-write stack --- .../src/webdriver_bidi_websocket_handshake.rs | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index d8ca9bd98..948d80089 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -11,6 +11,7 @@ use originweave_core::VerifiedWebDriverBiDiSocketPeer; use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; +const REDACTED_WEBSOCKET_CLIENT_NONCE: &str = ""; /// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. /// @@ -60,10 +61,20 @@ impl Error for WebDriverBiDiWebSocketHandshakeError {} /// RFC 6455 requires `Sec-WebSocket-Key` to be a nonce of 16 bytes encoded with base64. This type /// validates only the canonical wire representation, including zero padding bits. It does not /// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce -/// for each connection attempt. -#[derive(Debug, Eq, PartialEq)] +/// for each connection attempt. Its [`fmt::Debug`] representation deliberately redacts the nonce so +/// diagnostic output cannot disclose handshake material. +#[derive(Eq, PartialEq)] pub struct WebDriverBiDiWebSocketClientKey(String); +impl fmt::Debug for WebDriverBiDiWebSocketClientKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("WebDriverBiDiWebSocketClientKey") + .field(&REDACTED_WEBSOCKET_CLIENT_NONCE) + .finish() + } +} + impl WebDriverBiDiWebSocketClientKey { /// Admit one canonical base64 client key representing exactly 16 bytes. pub fn new(value: &str) -> Result { @@ -87,18 +98,29 @@ impl WebDriverBiDiWebSocketClientKey { /// the fixed WebSocket version-13 request required for the admitted `/session/` resource /// and retains the exact client key required to validate a later `Sec-WebSocket-Accept` response. /// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary -/// before any WebSocket bytes may be written. +/// before any WebSocket bytes may be written. Its [`fmt::Debug`] representation omits the serialized +/// request and redacts the client nonce because the request embeds that nonce in `Sec-WebSocket-Key`. /// /// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` /// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or /// Agent-authority grant. -#[derive(Debug)] pub struct WebDriverBiDiWebSocketHandshakePlan { connection: WebDriverBiDiTcpConnection, client_key: WebDriverBiDiWebSocketClientKey, request: Vec, } +impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketHandshakePlan") + .field("verified_peer", self.connection.verified_peer()) + .field("client_nonce", &REDACTED_WEBSOCKET_CLIENT_NONCE) + .field("request_byte_count", &self.request.len()) + .finish() + } +} + impl WebDriverBiDiWebSocketHandshakePlan { /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. pub fn new( From 1d873b1f74fa3facf4d5abdbbf443fd8ce998d18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:13:20 -0700 Subject: [PATCH 309/570] fix(network): adapt locateNodes exchange to validated WebSocket exports --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 7162f9161..d7c15f2ca 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -10,7 +10,7 @@ use originweave_core::{ WebDriverBiDiResponseDocumentAdmissionError, }; -use crate::webdriver_bidi_websocket_handshake::{ +use crate::{ MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketMaskKey, }; From b5fb68aeceb904b7951199c8048eeb8d99411a16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:17:02 -0700 Subject: [PATCH 310/570] test(network): preserve deterministic Pong write fault injection --- ...er_bidi_locate_nodes_exchange_transport_failure_tests.rs | 2 +- .../src/webdriver_bidi_websocket_validated.rs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs index 14c5cad2a..3c0ed55b4 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -118,7 +118,7 @@ fn locate_nodes_exchange_preserves_pong_write_failure_after_ping() -> Result<(), let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; let written = plan.write_opening_request(Duration::from_millis(500))?; let established = written.read_opening_response(Duration::from_millis(500))?; - let shutdown_stream = established.stream.try_clone()?; + let shutdown_stream = established.try_clone_stream_for_test()?; let pong_key = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); let exchanged = established.exchange_locate_nodes( locate_nodes_command()?, diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index d32eab98d..bcbe2428f 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -166,6 +166,12 @@ impl WebDriverBiDiWebSocketEstablished { self.0.write_timeout() } + /// Clone the exact underlying stream for crate-internal fault-injection tests only. + #[cfg(test)] + pub(crate) fn try_clone_stream_for_test(&self) -> std::io::Result { + self.0.stream.try_clone() + } + /// Write one unfragmented, masked UTF-8 text frame on this verified stream. pub fn write_text_frame( self, From 99ee011e0903f2983dc3a56f843ffab20672102e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 06:21:06 -0700 Subject: [PATCH 311/570] docs(evidence): disclose shared path tightening --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 235692cf7..2e27a4be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,7 +71,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Revocation is reported as not configured; the product makes no OCSP or CRL validation claim without supplied revocation evidence. - Every generic network header and query value is redacted before evidence leaves the trusted boundary, including conventionally benign field names containing attacker-controlled bytes. - Evidence capture enforces count and byte bounds and rejects credential-bearing source URLs, query strings, fragments, controls, whitespace, malformed percent escapes, encoded separators, dot segments, and backslash paths. -- Provenance source URL paths accept only RFC 3986 literal `pchar` syntax plus validated percent-encoded octets and slash separators, preventing raw general delimiters such as `[` and `]` or other invalid URI-presentation bytes from entering provenance identity. +- Network-evidence paths and provenance source URL paths accept only RFC 3986 literal `pchar` syntax plus validated percent-encoded octets and slash separators, preventing raw general delimiters such as `[` and `]` or other invalid URI-presentation bytes from entering either evidence surface. - Hard RAM and VRAM pressure pauses the active agent and rejects new admission; hard VRAM pressure also offloads a resident local model. - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. From 1de60eccab46174546005c80b5bf9e35286f26ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 06:23:37 -0700 Subject: [PATCH 312/570] docs(evidence): document shared RFC 3986 path boundary --- docs/doctoring.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/doctoring.md b/docs/doctoring.md index 6fb8a984c..ef12ebac0 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -82,7 +82,7 @@ RFC 9309 standardizes robots parsing, matching, error handling, and caching. It W3C PROV-O supplies interoperable Entity, Activity, Agent, derivation, attribution, and responsibility concepts. ISO 28500:2017, confirmed in 2023, defines WARC storage for protocol payloads, control information, metadata, transformations, duplicate detection, integrity, and segmentation. OriginWeave uses source hashes and locators in the safety kernel, then adds WARC and PROV adapters as separately testable modules. -RFC 3986 remains Internet Standard STD 66 for generic URI syntax and is updated by RFC 7320 and RFC 8820 without replacing its path grammar. Section 3.3 defines each path segment as `*pchar`, where literal path characters are unreserved characters, sub-delimiters, `:`, or `@`; `/` separates segments and other reserved characters such as `[` and `]` are not literal `pchar`. OriginWeave provenance URL admission therefore accepts only that literal ASCII `pchar` set plus validated percent-encoded octets and explicit slash separators. Existing stricter evidence-safety rules continue to reject encoded separators, dot-segment ambiguity, controls, whitespace, query strings, fragments, backslashes, and credential-bearing authority. This is URI-presentation validation only; it does not authorize the source origin, destination, network access, capture, disclosure, or retention. +RFC 3986 remains Internet Standard STD 66 for generic URI syntax and is updated by RFC 7320 and RFC 8820 without replacing its path grammar. Section 3.3 defines each path segment as `*pchar`, where literal path characters are unreserved characters, sub-delimiters, `:`, or `@`; `/` separates segments and other reserved characters such as `[` and `]` are not literal `pchar`. OriginWeave's shared evidence-path validator therefore applies that literal ASCII `pchar` set plus validated percent-encoded octets and explicit slash separators to both `NetworkEvidence::capture` paths and provenance source-URL paths. Existing stricter evidence-safety rules continue to reject encoded separators, dot-segment ambiguity, controls, whitespace, query strings, fragments, backslashes, and credential-bearing authority. This fail-closed syntax tightening affects both evidence surfaces; it does not authorize the source origin, destination, network access, capture, disclosure, or retention. ### AI risk and prompt injection @@ -174,4 +174,4 @@ World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04695 -Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 \ No newline at end of file +Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 From a508b5a9af451424f35f509798f8684d249889c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 07:32:01 -0700 Subject: [PATCH 313/570] test(core): define fail-closed release benchmark decision contract --- .../tests/release_acceptance.rs | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 crates/originweave-core/tests/release_acceptance.rs diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs new file mode 100644 index 000000000..170bd18e9 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -0,0 +1,162 @@ +#![allow(clippy::expect_used)] + +use originweave_core::release_acceptance::{ + BenchmarkSuite, BenchmarkSuiteOutcome, ReleaseDecision, ReleaseDecisionError, decide_release, +}; + +fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { + BenchmarkSuite::ALL + .into_iter() + .map(|suite| (suite, BenchmarkSuiteOutcome::Passed)) + .collect() +} + +#[test] +fn complete_passing_evidence_is_accepted_without_declared_limitations() { + let report = decide_release(passing_results(), false).expect("complete unique suite evidence"); + + assert_eq!(report.decision(), ReleaseDecision::Accepted); + assert!(report.failed_suites().is_empty()); + assert!(report.inconclusive_suites().is_empty()); + assert!(report.missing_suites().is_empty()); +} + +#[test] +fn complete_passing_evidence_preserves_declared_limitation_decision() { + let report = decide_release(passing_results(), true).expect("complete unique suite evidence"); + + assert_eq!( + report.decision(), + ReleaseDecision::AcceptedWithDeclaredLimitations + ); +} + +#[test] +fn every_mandatory_suite_is_required_for_acceptance() { + for omitted_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .filter(|(suite, _)| *suite != omitted_suite) + .collect::>(); + + let report = decide_release(evidence, false).expect("remaining suite identities are unique"); + + assert_eq!(report.decision(), ReleaseDecision::Inconclusive); + assert_eq!(report.missing_suites(), &[omitted_suite]); + assert!(report.failed_suites().is_empty()); + } +} + +#[test] +fn explicit_inconclusive_suite_evidence_cannot_be_promoted_to_acceptance() { + for inconclusive_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .map(|(suite, outcome)| { + if suite == inconclusive_suite { + (suite, BenchmarkSuiteOutcome::Inconclusive) + } else { + (suite, outcome) + } + }) + .collect::>(); + + let report = decide_release(evidence, true).expect("suite identities are unique"); + + assert_eq!(report.decision(), ReleaseDecision::Inconclusive); + assert_eq!(report.inconclusive_suites(), &[inconclusive_suite]); + } +} + +#[test] +fn any_known_threshold_failure_rejects_release_and_identifies_the_suite() { + for failed_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .map(|(suite, outcome)| { + if suite == failed_suite { + (suite, BenchmarkSuiteOutcome::Failed) + } else { + (suite, outcome) + } + }) + .collect::>(); + + let report = decide_release(evidence, true).expect("suite identities are unique"); + + assert_eq!(report.decision(), ReleaseDecision::Rejected); + assert_eq!(report.failed_suites(), &[failed_suite]); + } +} + +#[test] +fn known_failure_remains_rejected_when_other_evidence_is_incomplete() { + let report = decide_release( + [ + ( + BenchmarkSuite::ControlledDeterministic, + BenchmarkSuiteOutcome::Failed, + ), + ( + BenchmarkSuite::WebCompatibility, + BenchmarkSuiteOutcome::Inconclusive, + ), + ], + false, + ) + .expect("suite identities are unique"); + + assert_eq!(report.decision(), ReleaseDecision::Rejected); + assert_eq!( + report.failed_suites(), + &[BenchmarkSuite::ControlledDeterministic] + ); + assert_eq!( + report.inconclusive_suites(), + &[BenchmarkSuite::WebCompatibility] + ); + assert_eq!( + report.missing_suites(), + &[ + BenchmarkSuite::SecurityAdversarial, + BenchmarkSuite::ReliabilityRecovery, + BenchmarkSuite::EnterpriseOperability, + ] + ); +} + +#[test] +fn duplicate_suite_evidence_fails_closed_instead_of_overwriting_results() { + for duplicate_suite in BenchmarkSuite::ALL { + let error = decide_release( + [ + (duplicate_suite, BenchmarkSuiteOutcome::Passed), + (duplicate_suite, BenchmarkSuiteOutcome::Failed), + ], + false, + ) + .expect_err("duplicate suite evidence must fail closed"); + + assert_eq!(error, ReleaseDecisionError::DuplicateSuite(duplicate_suite)); + assert_eq!( + error.to_string(), + format!( + "benchmark release evidence contains duplicate suite: {}", + duplicate_suite.as_str() + ) + ); + let standard_error: &dyn std::error::Error = &error; + assert!(standard_error.source().is_none()); + } +} + +#[test] +fn decision_is_independent_of_evidence_input_order() { + let mut reversed = passing_results(); + reversed.reverse(); + + assert_eq!( + decide_release(reversed, false).expect("suite identities are unique"), + decide_release(passing_results(), false).expect("suite identities are unique") + ); +} From 3608eec1e8ffddc09b3a43ed098733a99e7da9f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 07:35:31 -0700 Subject: [PATCH 314/570] style(core): apply canonical release decision test formatting --- crates/originweave-core/tests/release_acceptance.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index 170bd18e9..295779f0b 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -39,7 +39,8 @@ fn every_mandatory_suite_is_required_for_acceptance() { .filter(|(suite, _)| *suite != omitted_suite) .collect::>(); - let report = decide_release(evidence, false).expect("remaining suite identities are unique"); + let report = + decide_release(evidence, false).expect("remaining suite identities are unique"); assert_eq!(report.decision(), ReleaseDecision::Inconclusive); assert_eq!(report.missing_suites(), &[omitted_suite]); From 426eb21fa06b58e291ee3d2ca6896eeaa60ad50a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 07:39:23 -0700 Subject: [PATCH 315/570] feat(core): implement fail-closed release benchmark decision --- crates/originweave-core/src/lib.rs | 186 +++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index b6ed55ff2..9404678f3 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1089,3 +1089,189 @@ pub fn evaluate_extension_access( } ExtensionAccessDecision::Allow } + +/// Deterministic release-acceptance aggregation for the commercial benchmark gate. +pub mod release_acceptance { + use std::fmt; + + /// One mandatory benchmark suite in the release acceptance contract. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub enum BenchmarkSuite { + /// Controlled local fixtures with deterministic post-condition oracles. + ControlledDeterministic, + /// Stable web compatibility tasks for the declared support profile. + WebCompatibility, + /// Hostile security cases that measure unauthorized authority or disclosure. + SecurityAdversarial, + /// Crash, timeout, retry, reconciliation, cleanup, and restore behavior. + ReliabilityRecovery, + /// Enterprise isolation, identity, policy, audit, and operator controls. + EnterpriseOperability, + } + + impl BenchmarkSuite { + /// Every mandatory benchmark suite in canonical release-report order. + pub const ALL: [Self; 5] = [ + Self::ControlledDeterministic, + Self::WebCompatibility, + Self::SecurityAdversarial, + Self::ReliabilityRecovery, + Self::EnterpriseOperability, + ]; + + /// Return the stable snake-case suite identifier used by benchmark evidence. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ControlledDeterministic => "controlled_deterministic_suite", + Self::WebCompatibility => "web_compatibility_suite", + Self::SecurityAdversarial => "security_adversarial_suite", + Self::ReliabilityRecovery => "reliability_recovery_suite", + Self::EnterpriseOperability => "enterprise_operability_suite", + } + } + + const fn index(self) -> usize { + match self { + Self::ControlledDeterministic => 0, + Self::WebCompatibility => 1, + Self::SecurityAdversarial => 2, + Self::ReliabilityRecovery => 3, + Self::EnterpriseOperability => 4, + } + } + } + + /// Evaluated outcome for one mandatory benchmark suite. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum BenchmarkSuiteOutcome { + /// Every threshold required for the declared profile passed. + Passed, + /// At least one mandatory threshold is known to have failed. + Failed, + /// Evidence is insufficient to establish either pass or threshold failure. + Inconclusive, + } + + /// Deterministic release decision produced from mandatory suite evidence. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum ReleaseDecision { + /// Every mandatory suite passed for the full declared support profile. + Accepted, + /// Every mandatory suite passed after buyer-visible limitations were declared. + AcceptedWithDeclaredLimitations, + /// At least one mandatory suite is known to have failed its threshold. + Rejected, + /// No known threshold failure exists, but mandatory evidence is incomplete. + Inconclusive, + } + + /// Fail-closed input error while constructing a release decision. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum ReleaseDecisionError { + /// The same suite appeared more than once instead of one authoritative result. + DuplicateSuite(BenchmarkSuite), + } + + impl fmt::Display for ReleaseDecisionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateSuite(suite) => write!( + formatter, + "benchmark release evidence contains duplicate suite: {}", + suite.as_str() + ), + } + } + } + + impl std::error::Error for ReleaseDecisionError {} + + /// Release decision together with exact mandatory-suite evidence gaps and failures. + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct ReleaseDecisionReport { + decision: ReleaseDecision, + failed_suites: Vec, + inconclusive_suites: Vec, + missing_suites: Vec, + } + + impl ReleaseDecisionReport { + /// Return the deterministic release decision. + #[must_use] + pub const fn decision(&self) -> ReleaseDecision { + self.decision + } + + /// Return suites with a known mandatory-threshold failure. + #[must_use] + pub fn failed_suites(&self) -> &[BenchmarkSuite] { + &self.failed_suites + } + + /// Return suites whose supplied evidence was explicitly inconclusive. + #[must_use] + pub fn inconclusive_suites(&self) -> &[BenchmarkSuite] { + &self.inconclusive_suites + } + + /// Return mandatory suites for which no outcome was supplied. + #[must_use] + pub fn missing_suites(&self) -> &[BenchmarkSuite] { + &self.missing_suites + } + } + + /// Produce one deterministic release decision from mandatory suite outcomes. + /// + /// Duplicate suite evidence fails closed rather than selecting an arbitrary + /// result. A known mandatory-threshold failure is always rejected, even when + /// other suites are missing or inconclusive; all such evidence gaps remain in + /// the returned report. Without a known failure, missing or inconclusive + /// evidence is never promoted to acceptance. + pub fn decide_release( + results: I, + has_declared_limitations: bool, + ) -> Result + where + I: IntoIterator, + { + let mut outcomes = [None; BenchmarkSuite::ALL.len()]; + for (suite, outcome) in results { + let slot = &mut outcomes[suite.index()]; + if slot.is_some() { + return Err(ReleaseDecisionError::DuplicateSuite(suite)); + } + *slot = Some(outcome); + } + + let mut failed_suites = Vec::new(); + let mut inconclusive_suites = Vec::new(); + let mut missing_suites = Vec::new(); + for suite in BenchmarkSuite::ALL { + match outcomes[suite.index()] { + Some(BenchmarkSuiteOutcome::Passed) => {} + Some(BenchmarkSuiteOutcome::Failed) => failed_suites.push(suite), + Some(BenchmarkSuiteOutcome::Inconclusive) => inconclusive_suites.push(suite), + None => missing_suites.push(suite), + } + } + + let decision = if !failed_suites.is_empty() { + ReleaseDecision::Rejected + } else if !inconclusive_suites.is_empty() || !missing_suites.is_empty() { + ReleaseDecision::Inconclusive + } else if has_declared_limitations { + ReleaseDecision::AcceptedWithDeclaredLimitations + } else { + ReleaseDecision::Accepted + }; + + Ok(ReleaseDecisionReport { + decision, + failed_suites, + inconclusive_suites, + missing_suites, + }) + } +} From ed6ac0debfa2f1353cf85e11ec9e8203bfc0e2e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 07:43:37 -0700 Subject: [PATCH 316/570] test(core): cover duplicate vector release evidence --- crates/originweave-core/tests/release_acceptance.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index 295779f0b..7922dd684 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -151,6 +151,18 @@ fn duplicate_suite_evidence_fails_closed_instead_of_overwriting_results() { } } +#[test] +fn duplicate_suite_evidence_in_vector_input_also_fails_closed() { + let duplicate_suite = BenchmarkSuite::ControlledDeterministic; + let mut evidence = passing_results(); + evidence.push((duplicate_suite, BenchmarkSuiteOutcome::Failed)); + + assert_eq!( + decide_release(evidence, false), + Err(ReleaseDecisionError::DuplicateSuite(duplicate_suite)) + ); +} + #[test] fn decision_is_independent_of_evidence_input_order() { let mut reversed = passing_results(); From f92eccc01a57cd9851a08c9c94faefa8718c2265 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 08:28:47 -0700 Subject: [PATCH 317/570] test(core): require explicit release limitations --- .../tests/release_acceptance.rs | 99 +++++++++++++++---- 1 file changed, 78 insertions(+), 21 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index 7922dd684..6b8001925 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -1,7 +1,6 @@ -#![allow(clippy::expect_used)] - use originweave_core::release_acceptance::{ - BenchmarkSuite, BenchmarkSuiteOutcome, ReleaseDecision, ReleaseDecisionError, decide_release, + BenchmarkSuite, BenchmarkSuiteOutcome, DeclaredLimitation, ReleaseDecision, ReleaseDecisionError, + decide_release, }; fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { @@ -11,24 +10,71 @@ fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { .collect() } +fn declared_limitation() -> DeclaredLimitation { + let Ok(limitation) = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is not included in the declared release support profile.", + ) else { + panic!("fixture limitation must be valid"); + }; + limitation +} + #[test] fn complete_passing_evidence_is_accepted_without_declared_limitations() { - let report = decide_release(passing_results(), false).expect("complete unique suite evidence"); + let Ok(report) = decide_release(passing_results(), &[]) else { + panic!("complete unique suite evidence must produce a report"); + }; assert_eq!(report.decision(), ReleaseDecision::Accepted); assert!(report.failed_suites().is_empty()); assert!(report.inconclusive_suites().is_empty()); assert!(report.missing_suites().is_empty()); + assert!(report.declared_limitations().is_empty()); } #[test] -fn complete_passing_evidence_preserves_declared_limitation_decision() { - let report = decide_release(passing_results(), true).expect("complete unique suite evidence"); +fn complete_passing_evidence_preserves_declared_limitation_details() { + let limitation = declared_limitation(); + let Ok(report) = decide_release(passing_results(), std::slice::from_ref(&limitation)) else { + panic!("complete unique suite evidence must produce a report"); + }; assert_eq!( report.decision(), ReleaseDecision::AcceptedWithDeclaredLimitations ); + assert_eq!(report.declared_limitations(), &[limitation]); +} + +#[test] +fn limitation_requires_an_unsupported_claim() { + assert_eq!( + DeclaredLimitation::new( + " ", + "A buyer-visible consequence must not stand without the narrowed claim.", + ), + Err(ReleaseDecisionError::EmptyLimitationClaim) + ); +} + +#[test] +fn limitation_requires_a_buyer_visible_consequence() { + assert_eq!( + DeclaredLimitation::new("linux_arm64", "\t\n"), + Err(ReleaseDecisionError::EmptyLimitationConsequence) + ); +} + +#[test] +fn limitation_exposes_the_exact_narrowed_claim_and_consequence() { + let limitation = declared_limitation(); + + assert_eq!(limitation.unsupported_claim(), "linux_arm64"); + assert_eq!( + limitation.buyer_consequence(), + "Linux ARM64 is not included in the declared release support profile." + ); } #[test] @@ -39,8 +85,9 @@ fn every_mandatory_suite_is_required_for_acceptance() { .filter(|(suite, _)| *suite != omitted_suite) .collect::>(); - let report = - decide_release(evidence, false).expect("remaining suite identities are unique"); + let Ok(report) = decide_release(evidence, &[]) else { + panic!("remaining suite identities must be unique"); + }; assert_eq!(report.decision(), ReleaseDecision::Inconclusive); assert_eq!(report.missing_suites(), &[omitted_suite]); @@ -61,11 +108,15 @@ fn explicit_inconclusive_suite_evidence_cannot_be_promoted_to_acceptance() { } }) .collect::>(); + let limitation = declared_limitation(); - let report = decide_release(evidence, true).expect("suite identities are unique"); + let Ok(report) = decide_release(evidence, std::slice::from_ref(&limitation)) else { + panic!("suite identities must be unique"); + }; assert_eq!(report.decision(), ReleaseDecision::Inconclusive); assert_eq!(report.inconclusive_suites(), &[inconclusive_suite]); + assert_eq!(report.declared_limitations(), &[limitation]); } } @@ -82,17 +133,21 @@ fn any_known_threshold_failure_rejects_release_and_identifies_the_suite() { } }) .collect::>(); + let limitation = declared_limitation(); - let report = decide_release(evidence, true).expect("suite identities are unique"); + let Ok(report) = decide_release(evidence, std::slice::from_ref(&limitation)) else { + panic!("suite identities must be unique"); + }; assert_eq!(report.decision(), ReleaseDecision::Rejected); assert_eq!(report.failed_suites(), &[failed_suite]); + assert_eq!(report.declared_limitations(), &[limitation]); } } #[test] fn known_failure_remains_rejected_when_other_evidence_is_incomplete() { - let report = decide_release( + let Ok(report) = decide_release( [ ( BenchmarkSuite::ControlledDeterministic, @@ -103,9 +158,10 @@ fn known_failure_remains_rejected_when_other_evidence_is_incomplete() { BenchmarkSuiteOutcome::Inconclusive, ), ], - false, - ) - .expect("suite identities are unique"); + &[], + ) else { + panic!("suite identities must be unique"); + }; assert_eq!(report.decision(), ReleaseDecision::Rejected); assert_eq!( @@ -129,14 +185,15 @@ fn known_failure_remains_rejected_when_other_evidence_is_incomplete() { #[test] fn duplicate_suite_evidence_fails_closed_instead_of_overwriting_results() { for duplicate_suite in BenchmarkSuite::ALL { - let error = decide_release( + let Err(error) = decide_release( [ (duplicate_suite, BenchmarkSuiteOutcome::Passed), (duplicate_suite, BenchmarkSuiteOutcome::Failed), ], - false, - ) - .expect_err("duplicate suite evidence must fail closed"); + &[], + ) else { + panic!("duplicate suite evidence must fail closed"); + }; assert_eq!(error, ReleaseDecisionError::DuplicateSuite(duplicate_suite)); assert_eq!( @@ -158,7 +215,7 @@ fn duplicate_suite_evidence_in_vector_input_also_fails_closed() { evidence.push((duplicate_suite, BenchmarkSuiteOutcome::Failed)); assert_eq!( - decide_release(evidence, false), + decide_release(evidence, &[]), Err(ReleaseDecisionError::DuplicateSuite(duplicate_suite)) ); } @@ -169,7 +226,7 @@ fn decision_is_independent_of_evidence_input_order() { reversed.reverse(); assert_eq!( - decide_release(reversed, false).expect("suite identities are unique"), - decide_release(passing_results(), false).expect("suite identities are unique") + decide_release(reversed, &[]), + decide_release(passing_results(), &[]) ); } From df7b164c1bbe88c1cf2d88a08e91da94c3ab732f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 08:30:41 -0700 Subject: [PATCH 318/570] test(core): format release limitation regression --- crates/originweave-core/tests/release_acceptance.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index 6b8001925..d466dfc77 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -1,6 +1,6 @@ use originweave_core::release_acceptance::{ - BenchmarkSuite, BenchmarkSuiteOutcome, DeclaredLimitation, ReleaseDecision, ReleaseDecisionError, - decide_release, + BenchmarkSuite, BenchmarkSuiteOutcome, DeclaredLimitation, ReleaseDecision, + ReleaseDecisionError, decide_release, }; fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { From 7f859b663f134c838a5dfa288c38dd09bdc2bd42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 08:35:10 -0700 Subject: [PATCH 319/570] fix(core): bind release limitations to buyer consequences --- crates/originweave-core/src/lib.rs | 77 ++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 9404678f3..a19b97170 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1153,6 +1153,53 @@ pub mod release_acceptance { Inconclusive, } + /// One explicit narrowed release claim and its buyer-visible consequence. + /// + /// An accepted-with-limitations decision cannot be produced from an opaque + /// boolean. Every limitation must name the unsupported claim and state the + /// consequence that a buyer must account for in the declared support profile. + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct DeclaredLimitation { + unsupported_claim: String, + buyer_consequence: String, + } + + impl DeclaredLimitation { + /// Construct one explicit buyer-visible release limitation. + /// + /// Whitespace-only claims or consequences fail closed because they cannot + /// narrow a release claim or communicate a usable buyer consequence. + pub fn new( + unsupported_claim: impl Into, + buyer_consequence: impl Into, + ) -> Result { + let unsupported_claim = unsupported_claim.into(); + if unsupported_claim.trim().is_empty() { + return Err(ReleaseDecisionError::EmptyLimitationClaim); + } + let buyer_consequence = buyer_consequence.into(); + if buyer_consequence.trim().is_empty() { + return Err(ReleaseDecisionError::EmptyLimitationConsequence); + } + Ok(Self { + unsupported_claim, + buyer_consequence, + }) + } + + /// Return the exact unsupported or narrowed release claim. + #[must_use] + pub fn unsupported_claim(&self) -> &str { + &self.unsupported_claim + } + + /// Return the exact consequence exposed to buyers and operators. + #[must_use] + pub fn buyer_consequence(&self) -> &str { + &self.buyer_consequence + } + } + /// Deterministic release decision produced from mandatory suite evidence. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReleaseDecision { @@ -1169,6 +1216,10 @@ pub mod release_acceptance { /// Fail-closed input error while constructing a release decision. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReleaseDecisionError { + /// A declared limitation did not identify the unsupported release claim. + EmptyLimitationClaim, + /// A declared limitation did not state the buyer-visible consequence. + EmptyLimitationConsequence, /// The same suite appeared more than once instead of one authoritative result. DuplicateSuite(BenchmarkSuite), } @@ -1176,6 +1227,12 @@ pub mod release_acceptance { impl fmt::Display for ReleaseDecisionError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::EmptyLimitationClaim => { + formatter.write_str("declared release limitation must name an unsupported claim") + } + Self::EmptyLimitationConsequence => formatter.write_str( + "declared release limitation must state a buyer-visible consequence", + ), Self::DuplicateSuite(suite) => write!( formatter, "benchmark release evidence contains duplicate suite: {}", @@ -1194,6 +1251,7 @@ pub mod release_acceptance { failed_suites: Vec, inconclusive_suites: Vec, missing_suites: Vec, + declared_limitations: Vec, } impl ReleaseDecisionReport { @@ -1220,6 +1278,12 @@ pub mod release_acceptance { pub fn missing_suites(&self) -> &[BenchmarkSuite] { &self.missing_suites } + + /// Return the exact buyer-visible limitations retained with this decision. + #[must_use] + pub fn declared_limitations(&self) -> &[DeclaredLimitation] { + &self.declared_limitations + } } /// Produce one deterministic release decision from mandatory suite outcomes. @@ -1228,10 +1292,12 @@ pub mod release_acceptance { /// result. A known mandatory-threshold failure is always rejected, even when /// other suites are missing or inconclusive; all such evidence gaps remain in /// the returned report. Without a known failure, missing or inconclusive - /// evidence is never promoted to acceptance. + /// evidence is never promoted to acceptance. Accepted-with-limitations requires + /// at least one validated [`DeclaredLimitation`], so the decision cannot be + /// detached from the exact narrowed claim and buyer-visible consequence. pub fn decide_release( results: I, - has_declared_limitations: bool, + declared_limitations: &[DeclaredLimitation], ) -> Result where I: IntoIterator, @@ -1261,10 +1327,10 @@ pub mod release_acceptance { ReleaseDecision::Rejected } else if !inconclusive_suites.is_empty() || !missing_suites.is_empty() { ReleaseDecision::Inconclusive - } else if has_declared_limitations { - ReleaseDecision::AcceptedWithDeclaredLimitations - } else { + } else if declared_limitations.is_empty() { ReleaseDecision::Accepted + } else { + ReleaseDecision::AcceptedWithDeclaredLimitations }; Ok(ReleaseDecisionReport { @@ -1272,6 +1338,7 @@ pub mod release_acceptance { failed_suites, inconclusive_suites, missing_suites, + declared_limitations: declared_limitations.to_vec(), }) } } From 75130851a0f7ce528a7a36382eb026ac7942a0aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 08:39:34 -0700 Subject: [PATCH 320/570] docs: record current URI ownership RFC lineage --- docs/doctoring.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/doctoring.md b/docs/doctoring.md index ef12ebac0..419cc2a00 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -82,7 +82,7 @@ RFC 9309 standardizes robots parsing, matching, error handling, and caching. It W3C PROV-O supplies interoperable Entity, Activity, Agent, derivation, attribution, and responsibility concepts. ISO 28500:2017, confirmed in 2023, defines WARC storage for protocol payloads, control information, metadata, transformations, duplicate detection, integrity, and segmentation. OriginWeave uses source hashes and locators in the safety kernel, then adds WARC and PROV adapters as separately testable modules. -RFC 3986 remains Internet Standard STD 66 for generic URI syntax and is updated by RFC 7320 and RFC 8820 without replacing its path grammar. Section 3.3 defines each path segment as `*pchar`, where literal path characters are unreserved characters, sub-delimiters, `:`, or `@`; `/` separates segments and other reserved characters such as `[` and `]` are not literal `pchar`. OriginWeave's shared evidence-path validator therefore applies that literal ASCII `pchar` set plus validated percent-encoded octets and explicit slash separators to both `NetworkEvidence::capture` paths and provenance source-URL paths. Existing stricter evidence-safety rules continue to reject encoded separators, dot-segment ambiguity, controls, whitespace, query strings, fragments, backslashes, and credential-bearing authority. This fail-closed syntax tightening affects both evidence surfaces; it does not authorize the source origin, destination, network access, capture, disclosure, or retention. +RFC 3986 remains Internet Standard STD 66 for generic URI syntax. RFC 8820 is the current URI design-and-ownership Best Current Practice; it obsoletes RFC 7320 and updates RFC 3986 without replacing RFC 3986's path grammar. Section 3.3 of RFC 3986 defines each path segment as `*pchar`, where literal path characters are unreserved characters, sub-delimiters, `:`, or `@`; `/` separates segments and other reserved characters such as `[` and `]` are not literal `pchar`. OriginWeave's shared evidence-path validator therefore applies that literal ASCII `pchar` set plus validated percent-encoded octets and explicit slash separators to both `NetworkEvidence::capture` paths and provenance source-URL paths. Existing stricter evidence-safety rules continue to reject encoded separators, dot-segment ambiguity, controls, whitespace, query strings, fragments, backslashes, and credential-bearing authority. This fail-closed syntax tightening affects both evidence surfaces; it does not authorize the source origin, destination, network access, capture, disclosure, or retention. ### AI risk and prompt injection @@ -148,6 +148,10 @@ Microsoft. (2025, July 25). *Azure IP address 168.63.129.16 overview*. Microsoft Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). *Learning to orchestrate agents in natural language with the Conductor* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04388 +Nottingham, M. (2014). *URI design and ownership* (RFC 7320). Internet Engineering Task Force. https://doi.org/10.17487/RFC7320 + +Nottingham, M. (2020). *URI design and ownership* (RFC 8820; BCP 190). Internet Engineering Task Force. https://doi.org/10.17487/RFC8820 + Rescorla, E. (2026). *The Transport Layer Security (TLS) protocol version 1.3* (RFC 9846). Internet Engineering Task Force. https://doi.org/10.17487/RFC9846 Rustls Project Developers. (2026). *rustls 0.23.42* [Computer software]. https://docs.rs/rustls/0.23.42/rustls/ @@ -174,4 +178,4 @@ World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04695 -Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 +Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 \ No newline at end of file From 7ccc804b0f11e390d5ebf6c23532bf94a75c77e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 08:41:44 -0700 Subject: [PATCH 321/570] style(core): apply canonical release contract formatting --- crates/originweave-core/src/lib.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index a19b97170..54c0ef62e 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1227,9 +1227,8 @@ pub mod release_acceptance { impl fmt::Display for ReleaseDecisionError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::EmptyLimitationClaim => { - formatter.write_str("declared release limitation must name an unsupported claim") - } + Self::EmptyLimitationClaim => formatter + .write_str("declared release limitation must name an unsupported claim"), Self::EmptyLimitationConsequence => formatter.write_str( "declared release limitation must state a buyer-visible consequence", ), From 07021d4587588f20bb8789ad5295895eafdd24e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 08:44:11 -0700 Subject: [PATCH 322/570] test(core): satisfy strict release contract linting --- .../tests/release_acceptance.rs | 117 ++++++++++-------- 1 file changed, 68 insertions(+), 49 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index d466dfc77..9c65bf5e0 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -10,41 +10,38 @@ fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { .collect() } -fn declared_limitation() -> DeclaredLimitation { - let Ok(limitation) = DeclaredLimitation::new( +fn declared_limitation() -> Result { + DeclaredLimitation::new( "linux_arm64", "Linux ARM64 is not included in the declared release support profile.", - ) else { - panic!("fixture limitation must be valid"); - }; - limitation + ) } #[test] -fn complete_passing_evidence_is_accepted_without_declared_limitations() { - let Ok(report) = decide_release(passing_results(), &[]) else { - panic!("complete unique suite evidence must produce a report"); - }; +fn complete_passing_evidence_is_accepted_without_declared_limitations( +) -> Result<(), ReleaseDecisionError> { + let report = decide_release(passing_results(), &[])?; assert_eq!(report.decision(), ReleaseDecision::Accepted); assert!(report.failed_suites().is_empty()); assert!(report.inconclusive_suites().is_empty()); assert!(report.missing_suites().is_empty()); assert!(report.declared_limitations().is_empty()); + Ok(()) } #[test] -fn complete_passing_evidence_preserves_declared_limitation_details() { - let limitation = declared_limitation(); - let Ok(report) = decide_release(passing_results(), std::slice::from_ref(&limitation)) else { - panic!("complete unique suite evidence must produce a report"); - }; +fn complete_passing_evidence_preserves_declared_limitation_details( +) -> Result<(), ReleaseDecisionError> { + let limitation = declared_limitation()?; + let report = decide_release(passing_results(), std::slice::from_ref(&limitation))?; assert_eq!( report.decision(), ReleaseDecision::AcceptedWithDeclaredLimitations ); assert_eq!(report.declared_limitations(), &[limitation]); + Ok(()) } #[test] @@ -67,36 +64,58 @@ fn limitation_requires_a_buyer_visible_consequence() { } #[test] -fn limitation_exposes_the_exact_narrowed_claim_and_consequence() { - let limitation = declared_limitation(); +fn limitation_errors_have_deterministic_standard_error_contracts() { + let cases = [ + ( + ReleaseDecisionError::EmptyLimitationClaim, + "declared release limitation must name an unsupported claim", + ), + ( + ReleaseDecisionError::EmptyLimitationConsequence, + "declared release limitation must state a buyer-visible consequence", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + let standard_error: &dyn std::error::Error = &error; + assert!(standard_error.source().is_none()); + } +} + +#[test] +fn limitation_exposes_the_exact_narrowed_claim_and_consequence( +) -> Result<(), ReleaseDecisionError> { + let limitation = declared_limitation()?; assert_eq!(limitation.unsupported_claim(), "linux_arm64"); assert_eq!( limitation.buyer_consequence(), "Linux ARM64 is not included in the declared release support profile." ); + Ok(()) } #[test] -fn every_mandatory_suite_is_required_for_acceptance() { +fn every_mandatory_suite_is_required_for_acceptance() -> Result<(), ReleaseDecisionError> { for omitted_suite in BenchmarkSuite::ALL { let evidence = passing_results() .into_iter() .filter(|(suite, _)| *suite != omitted_suite) .collect::>(); - let Ok(report) = decide_release(evidence, &[]) else { - panic!("remaining suite identities must be unique"); - }; + let report = decide_release(evidence, &[])?; assert_eq!(report.decision(), ReleaseDecision::Inconclusive); assert_eq!(report.missing_suites(), &[omitted_suite]); assert!(report.failed_suites().is_empty()); } + Ok(()) } #[test] -fn explicit_inconclusive_suite_evidence_cannot_be_promoted_to_acceptance() { +fn explicit_inconclusive_suite_evidence_cannot_be_promoted_to_acceptance( +) -> Result<(), ReleaseDecisionError> { for inconclusive_suite in BenchmarkSuite::ALL { let evidence = passing_results() .into_iter() @@ -108,20 +127,20 @@ fn explicit_inconclusive_suite_evidence_cannot_be_promoted_to_acceptance() { } }) .collect::>(); - let limitation = declared_limitation(); + let limitation = declared_limitation()?; - let Ok(report) = decide_release(evidence, std::slice::from_ref(&limitation)) else { - panic!("suite identities must be unique"); - }; + let report = decide_release(evidence, std::slice::from_ref(&limitation))?; assert_eq!(report.decision(), ReleaseDecision::Inconclusive); assert_eq!(report.inconclusive_suites(), &[inconclusive_suite]); assert_eq!(report.declared_limitations(), &[limitation]); } + Ok(()) } #[test] -fn any_known_threshold_failure_rejects_release_and_identifies_the_suite() { +fn any_known_threshold_failure_rejects_release_and_identifies_the_suite( +) -> Result<(), ReleaseDecisionError> { for failed_suite in BenchmarkSuite::ALL { let evidence = passing_results() .into_iter() @@ -133,21 +152,21 @@ fn any_known_threshold_failure_rejects_release_and_identifies_the_suite() { } }) .collect::>(); - let limitation = declared_limitation(); + let limitation = declared_limitation()?; - let Ok(report) = decide_release(evidence, std::slice::from_ref(&limitation)) else { - panic!("suite identities must be unique"); - }; + let report = decide_release(evidence, std::slice::from_ref(&limitation))?; assert_eq!(report.decision(), ReleaseDecision::Rejected); assert_eq!(report.failed_suites(), &[failed_suite]); assert_eq!(report.declared_limitations(), &[limitation]); } + Ok(()) } #[test] -fn known_failure_remains_rejected_when_other_evidence_is_incomplete() { - let Ok(report) = decide_release( +fn known_failure_remains_rejected_when_other_evidence_is_incomplete( +) -> Result<(), ReleaseDecisionError> { + let report = decide_release( [ ( BenchmarkSuite::ControlledDeterministic, @@ -159,9 +178,7 @@ fn known_failure_remains_rejected_when_other_evidence_is_incomplete() { ), ], &[], - ) else { - panic!("suite identities must be unique"); - }; + )?; assert_eq!(report.decision(), ReleaseDecision::Rejected); assert_eq!( @@ -180,30 +197,32 @@ fn known_failure_remains_rejected_when_other_evidence_is_incomplete() { BenchmarkSuite::EnterpriseOperability, ] ); + Ok(()) } #[test] fn duplicate_suite_evidence_fails_closed_instead_of_overwriting_results() { for duplicate_suite in BenchmarkSuite::ALL { - let Err(error) = decide_release( - [ - (duplicate_suite, BenchmarkSuiteOutcome::Passed), - (duplicate_suite, BenchmarkSuiteOutcome::Failed), - ], - &[], - ) else { - panic!("duplicate suite evidence must fail closed"); - }; - - assert_eq!(error, ReleaseDecisionError::DuplicateSuite(duplicate_suite)); + let expected_error = ReleaseDecisionError::DuplicateSuite(duplicate_suite); assert_eq!( - error.to_string(), + decide_release( + [ + (duplicate_suite, BenchmarkSuiteOutcome::Passed), + (duplicate_suite, BenchmarkSuiteOutcome::Failed), + ], + &[], + ), + Err(expected_error) + ); + + assert_eq!( + expected_error.to_string(), format!( "benchmark release evidence contains duplicate suite: {}", duplicate_suite.as_str() ) ); - let standard_error: &dyn std::error::Error = &error; + let standard_error: &dyn std::error::Error = &expected_error; assert!(standard_error.source().is_none()); } } From d6659da240e69b48ac377cb78934270dd1f1c78a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 08:45:48 -0700 Subject: [PATCH 323/570] style(core): apply canonical release test formatting --- .../tests/release_acceptance.rs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index 9c65bf5e0..c1f9a5318 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -18,8 +18,8 @@ fn declared_limitation() -> Result { } #[test] -fn complete_passing_evidence_is_accepted_without_declared_limitations( -) -> Result<(), ReleaseDecisionError> { +fn complete_passing_evidence_is_accepted_without_declared_limitations() +-> Result<(), ReleaseDecisionError> { let report = decide_release(passing_results(), &[])?; assert_eq!(report.decision(), ReleaseDecision::Accepted); @@ -31,8 +31,8 @@ fn complete_passing_evidence_is_accepted_without_declared_limitations( } #[test] -fn complete_passing_evidence_preserves_declared_limitation_details( -) -> Result<(), ReleaseDecisionError> { +fn complete_passing_evidence_preserves_declared_limitation_details() +-> Result<(), ReleaseDecisionError> { let limitation = declared_limitation()?; let report = decide_release(passing_results(), std::slice::from_ref(&limitation))?; @@ -84,8 +84,8 @@ fn limitation_errors_have_deterministic_standard_error_contracts() { } #[test] -fn limitation_exposes_the_exact_narrowed_claim_and_consequence( -) -> Result<(), ReleaseDecisionError> { +fn limitation_exposes_the_exact_narrowed_claim_and_consequence() -> Result<(), ReleaseDecisionError> +{ let limitation = declared_limitation()?; assert_eq!(limitation.unsupported_claim(), "linux_arm64"); @@ -114,8 +114,8 @@ fn every_mandatory_suite_is_required_for_acceptance() -> Result<(), ReleaseDecis } #[test] -fn explicit_inconclusive_suite_evidence_cannot_be_promoted_to_acceptance( -) -> Result<(), ReleaseDecisionError> { +fn explicit_inconclusive_suite_evidence_cannot_be_promoted_to_acceptance() +-> Result<(), ReleaseDecisionError> { for inconclusive_suite in BenchmarkSuite::ALL { let evidence = passing_results() .into_iter() @@ -139,8 +139,8 @@ fn explicit_inconclusive_suite_evidence_cannot_be_promoted_to_acceptance( } #[test] -fn any_known_threshold_failure_rejects_release_and_identifies_the_suite( -) -> Result<(), ReleaseDecisionError> { +fn any_known_threshold_failure_rejects_release_and_identifies_the_suite() +-> Result<(), ReleaseDecisionError> { for failed_suite in BenchmarkSuite::ALL { let evidence = passing_results() .into_iter() @@ -164,8 +164,8 @@ fn any_known_threshold_failure_rejects_release_and_identifies_the_suite( } #[test] -fn known_failure_remains_rejected_when_other_evidence_is_incomplete( -) -> Result<(), ReleaseDecisionError> { +fn known_failure_remains_rejected_when_other_evidence_is_incomplete() +-> Result<(), ReleaseDecisionError> { let report = decide_release( [ ( From a326340ae50cb9df57b103b56c867f73df680f52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 09:00:52 -0700 Subject: [PATCH 324/570] test(core): reject control characters in release limitations --- .../tests/release_acceptance.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index c1f9a5318..70f84496d 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -63,6 +63,24 @@ fn limitation_requires_a_buyer_visible_consequence() { ); } +#[test] +fn limitation_rejects_control_characters_in_release_metadata() { + assert!( + DeclaredLimitation::new( + "linux_arm64\nforged_release_claim", + "Linux ARM64 is unsupported." + ) + .is_err() + ); + assert!( + DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is unsupported.\rforged_release_consequence" + ) + .is_err() + ); +} + #[test] fn limitation_errors_have_deterministic_standard_error_contracts() { let cases = [ From 29b93ff19a30d3b41b41e0318f3952dae7b83869 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 09:09:55 -0700 Subject: [PATCH 325/570] fix(core): reject control characters in release limitations --- crates/originweave-core/src/lib.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 54c0ef62e..8dbc4e406 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1167,8 +1167,8 @@ pub mod release_acceptance { impl DeclaredLimitation { /// Construct one explicit buyer-visible release limitation. /// - /// Whitespace-only claims or consequences fail closed because they cannot - /// narrow a release claim or communicate a usable buyer consequence. + /// Empty/whitespace-only values and embedded control characters fail closed because + /// they cannot safely represent one unambiguous buyer-visible release limitation. pub fn new( unsupported_claim: impl Into, buyer_consequence: impl Into, @@ -1177,10 +1177,16 @@ pub mod release_acceptance { if unsupported_claim.trim().is_empty() { return Err(ReleaseDecisionError::EmptyLimitationClaim); } + if unsupported_claim.chars().any(char::is_control) { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } let buyer_consequence = buyer_consequence.into(); if buyer_consequence.trim().is_empty() { return Err(ReleaseDecisionError::EmptyLimitationConsequence); } + if buyer_consequence.chars().any(char::is_control) { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } Ok(Self { unsupported_claim, buyer_consequence, @@ -1218,8 +1224,12 @@ pub mod release_acceptance { pub enum ReleaseDecisionError { /// A declared limitation did not identify the unsupported release claim. EmptyLimitationClaim, + /// A declared limitation claim contained a control character. + InvalidLimitationClaim, /// A declared limitation did not state the buyer-visible consequence. EmptyLimitationConsequence, + /// A declared limitation consequence contained a control character. + InvalidLimitationConsequence, /// The same suite appeared more than once instead of one authoritative result. DuplicateSuite(BenchmarkSuite), } @@ -1229,9 +1239,14 @@ pub mod release_acceptance { match self { Self::EmptyLimitationClaim => formatter .write_str("declared release limitation must name an unsupported claim"), + Self::InvalidLimitationClaim => formatter + .write_str("declared release limitation claim contains a control character"), Self::EmptyLimitationConsequence => formatter.write_str( "declared release limitation must state a buyer-visible consequence", ), + Self::InvalidLimitationConsequence => formatter.write_str( + "declared release limitation consequence contains a control character", + ), Self::DuplicateSuite(suite) => write!( formatter, "benchmark release evidence contains duplicate suite: {}", From b206245d7550d4f5d78e3803c33c61f440889a0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 09:10:33 -0700 Subject: [PATCH 326/570] test(core): assert release limitation validation errors --- .../tests/release_acceptance.rs | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index 70f84496d..739682aec 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -65,19 +65,19 @@ fn limitation_requires_a_buyer_visible_consequence() { #[test] fn limitation_rejects_control_characters_in_release_metadata() { - assert!( + assert_eq!( DeclaredLimitation::new( "linux_arm64\nforged_release_claim", "Linux ARM64 is unsupported." - ) - .is_err() + ), + Err(ReleaseDecisionError::InvalidLimitationClaim) ); - assert!( + assert_eq!( DeclaredLimitation::new( "linux_arm64", "Linux ARM64 is unsupported.\rforged_release_consequence" - ) - .is_err() + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence) ); } @@ -88,10 +88,18 @@ fn limitation_errors_have_deterministic_standard_error_contracts() { ReleaseDecisionError::EmptyLimitationClaim, "declared release limitation must name an unsupported claim", ), + ( + ReleaseDecisionError::InvalidLimitationClaim, + "declared release limitation claim contains a control character", + ), ( ReleaseDecisionError::EmptyLimitationConsequence, "declared release limitation must state a buyer-visible consequence", ), + ( + ReleaseDecisionError::InvalidLimitationConsequence, + "declared release limitation consequence contains a control character", + ), ]; for (error, expected_message) in cases { From b4ccf732decc84d10fef34170efe4392ae9bc6d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 09:14:00 -0700 Subject: [PATCH 327/570] docs(changelog): record release limitation metadata hardening --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d17419927..a9e17c711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security +- Release-acceptance limitation metadata rejects embedded control characters so buyer-visible narrowed claims and consequences cannot contain forged line breaks. - Raw page content cannot become a trusted instruction. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. From 1d8d1ac7093d8f90193b9f892516ba1acb3ec79f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 09:28:57 -0700 Subject: [PATCH 328/570] test(core): reject ambiguous release limitation formatting --- .../tests/release_acceptance.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index 739682aec..cb1670fde 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -81,6 +81,26 @@ fn limitation_rejects_control_characters_in_release_metadata() { ); } +#[test] +fn limitation_rejects_ambiguous_unicode_formatting_characters() { + for character in ['\u{202e}', '\u{200b}', '\u{00ad}', '\u{2066}', '\u{feff}'] { + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{character}forged_release_claim"), + "Linux ARM64 is unsupported." + ), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{character}forged_release_consequence") + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); + } +} + #[test] fn limitation_errors_have_deterministic_standard_error_contracts() { let cases = [ From 38bdc6fc4e963d5708138c9083dfeb74a23f7de8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 09:34:27 -0700 Subject: [PATCH 329/570] fix(core): reject ambiguous release limitation presentation --- crates/originweave-core/src/lib.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 8dbc4e406..ade2126eb 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1167,7 +1167,7 @@ pub mod release_acceptance { impl DeclaredLimitation { /// Construct one explicit buyer-visible release limitation. /// - /// Empty/whitespace-only values and embedded control characters fail closed because + /// Empty/whitespace-only values and ambiguous presentation characters fail closed because /// they cannot safely represent one unambiguous buyer-visible release limitation. pub fn new( unsupported_claim: impl Into, @@ -1177,14 +1177,20 @@ pub mod release_acceptance { if unsupported_claim.trim().is_empty() { return Err(ReleaseDecisionError::EmptyLimitationClaim); } - if unsupported_claim.chars().any(char::is_control) { + if unsupported_claim + .chars() + .any(disallowed_release_limitation_character) + { return Err(ReleaseDecisionError::InvalidLimitationClaim); } let buyer_consequence = buyer_consequence.into(); if buyer_consequence.trim().is_empty() { return Err(ReleaseDecisionError::EmptyLimitationConsequence); } - if buyer_consequence.chars().any(char::is_control) { + if buyer_consequence + .chars() + .any(disallowed_release_limitation_character) + { return Err(ReleaseDecisionError::InvalidLimitationConsequence); } Ok(Self { @@ -1206,6 +1212,15 @@ pub mod release_acceptance { } } + fn disallowed_release_limitation_character(character: char) -> bool { + let code_point = character as u32; + character.is_control() + || matches!( + code_point, + 0x00ad | 0x061c | 0x200b..=0x200f | 0x2028..=0x202e | 0x2060..=0x206f | 0xfeff + ) + } + /// Deterministic release decision produced from mandatory suite evidence. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReleaseDecision { @@ -1224,11 +1239,11 @@ pub mod release_acceptance { pub enum ReleaseDecisionError { /// A declared limitation did not identify the unsupported release claim. EmptyLimitationClaim, - /// A declared limitation claim contained a control character. + /// A declared limitation claim contained an unsafe presentation character. InvalidLimitationClaim, /// A declared limitation did not state the buyer-visible consequence. EmptyLimitationConsequence, - /// A declared limitation consequence contained a control character. + /// A declared limitation consequence contained an unsafe presentation character. InvalidLimitationConsequence, /// The same suite appeared more than once instead of one authoritative result. DuplicateSuite(BenchmarkSuite), From c9fcca865a8fffaed2ff5b0d263fc1951a88fb1d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 09:36:46 -0700 Subject: [PATCH 330/570] test(core): require precise limitation validation errors --- .../tests/release_acceptance.rs | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index cb1670fde..70fbe8a95 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -83,7 +83,10 @@ fn limitation_rejects_control_characters_in_release_metadata() { #[test] fn limitation_rejects_ambiguous_unicode_formatting_characters() { - for character in ['\u{202e}', '\u{200b}', '\u{00ad}', '\u{2066}', '\u{feff}'] { + for character in [ + '\u{00ad}', '\u{061c}', '\u{200b}', '\u{200f}', '\u{2028}', '\u{202e}', '\u{2060}', + '\u{2066}', '\u{206f}', '\u{feff}', + ] { assert_eq!( DeclaredLimitation::new( format!("linux_arm64{character}forged_release_claim"), @@ -101,6 +104,21 @@ fn limitation_rejects_ambiguous_unicode_formatting_characters() { } } +#[test] +fn limitation_preserves_unambiguous_international_buyer_text() -> Result<(), ReleaseDecisionError> { + let limitation = DeclaredLimitation::new( + "한국어_운영환경", + "이 운영환경은 현재 지원 범위에 포함되지 않습니다.", + )?; + + assert_eq!(limitation.unsupported_claim(), "한국어_운영환경"); + assert_eq!( + limitation.buyer_consequence(), + "이 운영환경은 현재 지원 범위에 포함되지 않습니다." + ); + Ok(()) +} + #[test] fn limitation_errors_have_deterministic_standard_error_contracts() { let cases = [ @@ -110,7 +128,7 @@ fn limitation_errors_have_deterministic_standard_error_contracts() { ), ( ReleaseDecisionError::InvalidLimitationClaim, - "declared release limitation claim contains a control character", + "declared release limitation claim contains an unsafe presentation character", ), ( ReleaseDecisionError::EmptyLimitationConsequence, @@ -118,7 +136,7 @@ fn limitation_errors_have_deterministic_standard_error_contracts() { ), ( ReleaseDecisionError::InvalidLimitationConsequence, - "declared release limitation consequence contains a control character", + "declared release limitation consequence contains an unsafe presentation character", ), ]; From 57d63a216d7bc90668e8d12fc7cec8acf546be65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 09:41:53 -0700 Subject: [PATCH 331/570] fix(core): report unsafe limitation presentation precisely --- crates/originweave-core/src/lib.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index ade2126eb..694225c75 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1254,13 +1254,14 @@ pub mod release_acceptance { match self { Self::EmptyLimitationClaim => formatter .write_str("declared release limitation must name an unsupported claim"), - Self::InvalidLimitationClaim => formatter - .write_str("declared release limitation claim contains a control character"), + Self::InvalidLimitationClaim => formatter.write_str( + "declared release limitation claim contains an unsafe presentation character", + ), Self::EmptyLimitationConsequence => formatter.write_str( "declared release limitation must state a buyer-visible consequence", ), Self::InvalidLimitationConsequence => formatter.write_str( - "declared release limitation consequence contains a control character", + "declared release limitation consequence contains an unsafe presentation character", ), Self::DuplicateSuite(suite) => write!( formatter, From 468c32cb0909ab76c634b30adad6df691874d7cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 09:45:03 -0700 Subject: [PATCH 332/570] docs: record release limitation presentation hardening --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e17c711..d01880d34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security -- Release-acceptance limitation metadata rejects embedded control characters so buyer-visible narrowed claims and consequences cannot contain forged line breaks. +- Release-acceptance limitation metadata rejects embedded controls plus bidirectional, invisible, and other ambiguous Unicode presentation characters while preserving ordinary international text, so buyer-visible narrowed claims and consequences cannot forge or visually reorder release statements. - Raw page content cannot become a trusted instruction. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. From 363b9ee2511019b6d1dadd7d5c60be0183bba121 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 09:49:27 -0700 Subject: [PATCH 333/570] test(core): reject Mongolian vowel separator in limitations --- crates/originweave-core/tests/release_acceptance.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index 70fbe8a95..a2da1c666 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -84,8 +84,8 @@ fn limitation_rejects_control_characters_in_release_metadata() { #[test] fn limitation_rejects_ambiguous_unicode_formatting_characters() { for character in [ - '\u{00ad}', '\u{061c}', '\u{200b}', '\u{200f}', '\u{2028}', '\u{202e}', '\u{2060}', - '\u{2066}', '\u{206f}', '\u{feff}', + '\u{00ad}', '\u{061c}', '\u{180e}', '\u{200b}', '\u{200f}', '\u{2028}', '\u{202e}', + '\u{2060}', '\u{2066}', '\u{206f}', '\u{feff}', ] { assert_eq!( DeclaredLimitation::new( From b54ed108397d513030cba7c4513c58cc8b76d9bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 10:02:02 -0700 Subject: [PATCH 334/570] fix(core): reject U+180E in release limitation metadata --- crates/originweave-core/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 694225c75..97116f180 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -267,7 +267,7 @@ impl BrowsingContextId { Ok(Self(value)) } - /// Return the validated browsing-context identifier. + /// Return the browsing-context identifier. #[must_use] pub const fn value(self) -> u64 { self.0 @@ -1217,7 +1217,7 @@ pub mod release_acceptance { character.is_control() || matches!( code_point, - 0x00ad | 0x061c | 0x200b..=0x200f | 0x2028..=0x202e | 0x2060..=0x206f | 0xfeff + 0x00ad | 0x061c | 0x180e | 0x200b..=0x200f | 0x2028..=0x202e | 0x2060..=0x206f | 0xfeff ) } From 71820f06bc31721f6effc15b106423ae39e0c41e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 10:03:47 -0700 Subject: [PATCH 335/570] docs(core): restore validated identifier wording --- crates/originweave-core/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 97116f180..62addb8ca 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -267,7 +267,7 @@ impl BrowsingContextId { Ok(Self(value)) } - /// Return the browsing-context identifier. + /// Return the validated browsing-context identifier. #[must_use] pub const fn value(self) -> u64 { self.0 From 7f8639a8b7a539aa80df969596ee87ab82776f1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 10:10:29 -0700 Subject: [PATCH 336/570] test(core): require Unicode 17 default-ignorable rejection --- .../tests/release_acceptance_unicode17.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 crates/originweave-core/tests/release_acceptance_unicode17.rs diff --git a/crates/originweave-core/tests/release_acceptance_unicode17.rs b/crates/originweave-core/tests/release_acceptance_unicode17.rs new file mode 100644 index 000000000..854513e06 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_unicode17.rs @@ -0,0 +1,72 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +#[test] +fn limitation_rejects_unicode_17_default_ignorable_code_points() { + // Unicode 17.0.0 DerivedCoreProperties.txt, Default_Ignorable_Code_Point. + // Endpoints plus a midpoint make every reviewed inclusive range executable evidence. + let ranges = [ + (0x00ad_u32, 0x00ad_u32), + (0x034f, 0x034f), + (0x061c, 0x061c), + (0x115f, 0x1160), + (0x17b4, 0x17b5), + (0x180b, 0x180f), + (0x200b, 0x200f), + (0x202a, 0x202e), + (0x2060, 0x206f), + (0x3164, 0x3164), + (0xfe00, 0xfe0f), + (0xfeff, 0xfeff), + (0xffa0, 0xffa0), + (0xfff0, 0xfff8), + (0x1bca0, 0x1bca3), + (0x1d173, 0x1d17a), + (0xe0000, 0xe0fff), + ]; + + for (start, end) in ranges { + let midpoint = start + (end - start) / 2; + for code_point in [start, midpoint, end] { + let character = char::from_u32(code_point) + .expect("Unicode 17 default-ignorable range contains only scalar values"); + + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{character}forged_release_claim"), + "Linux ARM64 is unsupported.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "U+{code_point:04X} must be rejected in the unsupported claim", + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{character}forged_release_consequence"), + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "U+{code_point:04X} must be rejected in the buyer consequence", + ); + } + } +} + +#[test] +fn limitation_does_not_blanket_reject_unicode_17_whitespace() -> Result<(), ReleaseDecisionError> { + let medium_mathematical_space = '\u{205f}'; + let ideographic_space = '\u{3000}'; + + let limitation = DeclaredLimitation::new( + format!("east{ideographic_space}asia"), + format!("Support is limited{medium_mathematical_space}to the declared profile."), + )?; + + assert_eq!( + limitation.unsupported_claim(), + format!("east{ideographic_space}asia") + ); + assert_eq!( + limitation.buyer_consequence(), + format!("Support is limited{medium_mathematical_space}to the declared profile.") + ); + Ok(()) +} From 3e1dfecf6fbdf0c408933922a9483d5957362720 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 10:16:39 -0700 Subject: [PATCH 337/570] fix(core): enforce Unicode 17 default-ignorable policy --- crates/originweave-core/src/lib.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 62addb8ca..1f4f679e9 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1217,7 +1217,23 @@ pub mod release_acceptance { character.is_control() || matches!( code_point, - 0x00ad | 0x061c | 0x180e | 0x200b..=0x200f | 0x2028..=0x202e | 0x2060..=0x206f | 0xfeff + 0x00ad + | 0x034f + | 0x061c + | 0x115f..=0x1160 + | 0x17b4..=0x17b5 + | 0x180b..=0x180f + | 0x200b..=0x200f + | 0x2028..=0x202e + | 0x2060..=0x206f + | 0x3164 + | 0xfe00..=0xfe0f + | 0xfeff + | 0xffa0 + | 0xfff0..=0xfff8 + | 0x1bca0..=0x1bca3 + | 0x1d173..=0x1d17a + | 0xe0000..=0xe0fff ) } From 5fc1c2f30ae590e7c64c53232915f18440e8a86b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 10:19:15 -0700 Subject: [PATCH 338/570] docs: pin release metadata Unicode policy --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d01880d34..0bb25c0f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security -- Release-acceptance limitation metadata rejects embedded controls plus bidirectional, invisible, and other ambiguous Unicode presentation characters while preserving ordinary international text, so buyer-visible narrowed claims and consequences cannot forge or visually reorder release statements. +- Release-acceptance limitation metadata rejects embedded controls, U+2028/U+2029 line and paragraph separators, and Unicode 17.0.0 `Default_Ignorable_Code_Point` characters while preserving ordinary international text, so buyer-visible narrowed claims and consequences cannot forge or invisibly alter release statements. - Raw page content cannot become a trusted instruction. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. @@ -55,7 +55,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Shortened, integer, hexadecimal, and legacy octal-looking IPv4 host spellings are rejected so the policy origin cannot diverge from Chromium host interpretation. - IPv4-mapped IPv6 is canonicalized before destination classification and pin comparison so mapped private or loopback addresses cannot bypass IPv4 policy. - The default destination policy permits only public addresses and denies unspecified, loopback, private, shared, link-local, metadata, documentation, benchmarking, multicast, broadcast, transition, and protocol-reserved destinations. -- Azure platform IP `168.63.129.16` and Amazon EKS Pod Identity endpoints `169.254.170.23` and `fd00:ec2::23` are classified as metadata or platform services before broader public, link-local, or unique-local rules. +- Azure platform IP `168.63.129.16` and Amazon EKS Pod Identity endpoints `169.254.170.23` and `fd00:ec2::23` are classified as metadata or platform services before broader address-range rules. - Resolver answers are rejected when empty or larger than 256 addresses, preventing an unbounded resolver response from entering policy state. - `localhost` may approve only loopback addresses, while literal IPv4 and IPv6 origins may approve only the exact canonical address encoded in the origin. - Resolver answers must remain a non-empty subset of the origin-bound approved address set; any newly introduced address fails closed as a possible DNS-rebinding event. From 0e9edb481d13a6779d9915b59eb7a44f928ceac1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 10:31:42 -0700 Subject: [PATCH 339/570] test(core): satisfy strict Unicode regression lint --- .../originweave-core/tests/release_acceptance_unicode17.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance_unicode17.rs b/crates/originweave-core/tests/release_acceptance_unicode17.rs index 854513e06..f2bd5b020 100644 --- a/crates/originweave-core/tests/release_acceptance_unicode17.rs +++ b/crates/originweave-core/tests/release_acceptance_unicode17.rs @@ -1,7 +1,7 @@ use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; #[test] -fn limitation_rejects_unicode_17_default_ignorable_code_points() { +fn limitation_rejects_unicode_17_default_ignorable_code_points() -> Result<(), &'static str> { // Unicode 17.0.0 DerivedCoreProperties.txt, Default_Ignorable_Code_Point. // Endpoints plus a midpoint make every reviewed inclusive range executable evidence. let ranges = [ @@ -28,7 +28,7 @@ fn limitation_rejects_unicode_17_default_ignorable_code_points() { let midpoint = start + (end - start) / 2; for code_point in [start, midpoint, end] { let character = char::from_u32(code_point) - .expect("Unicode 17 default-ignorable range contains only scalar values"); + .ok_or("reviewed Unicode 17 default-ignorable range must contain scalar values")?; assert_eq!( DeclaredLimitation::new( @@ -48,6 +48,8 @@ fn limitation_rejects_unicode_17_default_ignorable_code_points() { ); } } + + Ok(()) } #[test] From db784dd5cdabdec0ec2079eaac21b1eb8ddcf6ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 11:03:34 -0700 Subject: [PATCH 340/570] test(core): bound release limitation metadata --- .../release_acceptance_resource_bounds.rs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 crates/originweave-core/tests/release_acceptance_resource_bounds.rs diff --git a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs new file mode 100644 index 000000000..60ecdb93a --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs @@ -0,0 +1,104 @@ +use originweave_core::release_acceptance::{ + BenchmarkSuite, BenchmarkSuiteOutcome, DeclaredLimitation, ReleaseDecision, + ReleaseDecisionError, MAX_DECLARED_RELEASE_LIMITATIONS, MAX_RELEASE_LIMITATION_TEXT_BYTES, + decide_release, +}; + +fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { + BenchmarkSuite::ALL + .into_iter() + .map(|suite| (suite, BenchmarkSuiteOutcome::Passed)) + .collect() +} + +#[test] +fn limitation_metadata_enforces_exact_utf8_byte_budget() -> Result<(), ReleaseDecisionError> { + let maximum_claim = "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); + let maximum_consequence = "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); + let limitation = DeclaredLimitation::new(maximum_claim.clone(), maximum_consequence.clone())?; + + assert_eq!(limitation.unsupported_claim(), maximum_claim.as_str()); + assert_eq!( + limitation.buyer_consequence(), + maximum_consequence.as_str() + ); + assert_eq!( + DeclaredLimitation::new( + "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1), + "bounded buyer consequence" + ), + Err(ReleaseDecisionError::LimitationClaimTooLong) + ); + assert_eq!( + DeclaredLimitation::new( + "bounded_claim", + "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1) + ), + Err(ReleaseDecisionError::LimitationConsequenceTooLong) + ); + Ok(()) +} + +#[test] +fn limitation_byte_budget_applies_to_international_text() { + let korean_character = "가"; + let repeated = korean_character.repeat( + MAX_RELEASE_LIMITATION_TEXT_BYTES / korean_character.len() + 1, + ); + assert!(repeated.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES); + assert_eq!( + DeclaredLimitation::new(repeated, "지원 범위를 설명하는 구매자 안내"), + Err(ReleaseDecisionError::LimitationClaimTooLong) + ); +} + +#[test] +fn release_report_bounds_declared_limitation_count_before_cloning() +-> Result<(), ReleaseDecisionError> { + let limitation = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is outside the declared support profile.", + )?; + let maximum = vec![limitation.clone(); MAX_DECLARED_RELEASE_LIMITATIONS]; + let report = decide_release(passing_results(), &maximum)?; + + assert_eq!( + report.decision(), + ReleaseDecision::AcceptedWithDeclaredLimitations + ); + assert_eq!( + report.declared_limitations().len(), + MAX_DECLARED_RELEASE_LIMITATIONS + ); + + let too_many = vec![limitation; MAX_DECLARED_RELEASE_LIMITATIONS + 1]; + assert_eq!( + decide_release(passing_results(), &too_many), + Err(ReleaseDecisionError::TooManyDeclaredLimitations) + ); + Ok(()) +} + +#[test] +fn release_resource_limit_errors_have_deterministic_standard_error_contracts() { + let cases = [ + ( + ReleaseDecisionError::LimitationClaimTooLong, + "declared release limitation claim exceeds the byte budget", + ), + ( + ReleaseDecisionError::LimitationConsequenceTooLong, + "declared release limitation consequence exceeds the byte budget", + ), + ( + ReleaseDecisionError::TooManyDeclaredLimitations, + "benchmark release decision contains too many declared limitations", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + let standard_error: &dyn std::error::Error = &error; + assert!(standard_error.source().is_none()); + } +} From 6cefbfc36a8a5c1fe19048910b18ffb6b1a18f88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 11:05:00 -0700 Subject: [PATCH 341/570] test(core): format release limitation bounds regression --- .../tests/release_acceptance_resource_bounds.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs index 60ecdb93a..94729086a 100644 --- a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs +++ b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs @@ -1,7 +1,6 @@ use originweave_core::release_acceptance::{ - BenchmarkSuite, BenchmarkSuiteOutcome, DeclaredLimitation, ReleaseDecision, - ReleaseDecisionError, MAX_DECLARED_RELEASE_LIMITATIONS, MAX_RELEASE_LIMITATION_TEXT_BYTES, - decide_release, + BenchmarkSuite, BenchmarkSuiteOutcome, DeclaredLimitation, MAX_DECLARED_RELEASE_LIMITATIONS, + MAX_RELEASE_LIMITATION_TEXT_BYTES, ReleaseDecision, ReleaseDecisionError, decide_release, }; fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { @@ -18,10 +17,7 @@ fn limitation_metadata_enforces_exact_utf8_byte_budget() -> Result<(), ReleaseDe let limitation = DeclaredLimitation::new(maximum_claim.clone(), maximum_consequence.clone())?; assert_eq!(limitation.unsupported_claim(), maximum_claim.as_str()); - assert_eq!( - limitation.buyer_consequence(), - maximum_consequence.as_str() - ); + assert_eq!(limitation.buyer_consequence(), maximum_consequence.as_str()); assert_eq!( DeclaredLimitation::new( "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1), @@ -42,9 +38,8 @@ fn limitation_metadata_enforces_exact_utf8_byte_budget() -> Result<(), ReleaseDe #[test] fn limitation_byte_budget_applies_to_international_text() { let korean_character = "가"; - let repeated = korean_character.repeat( - MAX_RELEASE_LIMITATION_TEXT_BYTES / korean_character.len() + 1, - ); + let repeated = + korean_character.repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES / korean_character.len() + 1); assert!(repeated.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES); assert_eq!( DeclaredLimitation::new(repeated, "지원 범위를 설명하는 구매자 안내"), From 540ae25add8153354ec52499ca90a390378f5c9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 11:11:10 -0700 Subject: [PATCH 342/570] fix(core): bound release limitation resources --- crates/originweave-core/src/lib.rs | 50 ++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index 1f4f679e9..efa4aac5d 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1094,6 +1094,12 @@ pub fn evaluate_extension_access( pub mod release_acceptance { use std::fmt; + /// Maximum UTF-8 byte length retained for either buyer-visible limitation field. + pub const MAX_RELEASE_LIMITATION_TEXT_BYTES: usize = 1024; + + /// Maximum number of buyer-visible limitations retained in one release report. + pub const MAX_DECLARED_RELEASE_LIMITATIONS: usize = 64; + /// One mandatory benchmark suite in the release acceptance contract. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum BenchmarkSuite { @@ -1167,8 +1173,9 @@ pub mod release_acceptance { impl DeclaredLimitation { /// Construct one explicit buyer-visible release limitation. /// - /// Empty/whitespace-only values and ambiguous presentation characters fail closed because - /// they cannot safely represent one unambiguous buyer-visible release limitation. + /// Empty/whitespace-only values, fields exceeding the fixed UTF-8 byte budget, + /// and ambiguous presentation characters fail closed because they cannot safely + /// represent one unambiguous, resource-bounded buyer-visible release limitation. pub fn new( unsupported_claim: impl Into, buyer_consequence: impl Into, @@ -1177,6 +1184,9 @@ pub mod release_acceptance { if unsupported_claim.trim().is_empty() { return Err(ReleaseDecisionError::EmptyLimitationClaim); } + if unsupported_claim.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { + return Err(ReleaseDecisionError::LimitationClaimTooLong); + } if unsupported_claim .chars() .any(disallowed_release_limitation_character) @@ -1187,6 +1197,9 @@ pub mod release_acceptance { if buyer_consequence.trim().is_empty() { return Err(ReleaseDecisionError::EmptyLimitationConsequence); } + if buyer_consequence.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { + return Err(ReleaseDecisionError::LimitationConsequenceTooLong); + } if buyer_consequence .chars() .any(disallowed_release_limitation_character) @@ -1255,12 +1268,18 @@ pub mod release_acceptance { pub enum ReleaseDecisionError { /// A declared limitation did not identify the unsupported release claim. EmptyLimitationClaim, + /// A declared limitation claim exceeded the fixed UTF-8 byte budget. + LimitationClaimTooLong, /// A declared limitation claim contained an unsafe presentation character. InvalidLimitationClaim, /// A declared limitation did not state the buyer-visible consequence. EmptyLimitationConsequence, + /// A declared limitation consequence exceeded the fixed UTF-8 byte budget. + LimitationConsequenceTooLong, /// A declared limitation consequence contained an unsafe presentation character. InvalidLimitationConsequence, + /// One release report supplied more buyer-visible limitations than the fixed resource budget. + TooManyDeclaredLimitations, /// The same suite appeared more than once instead of one authoritative result. DuplicateSuite(BenchmarkSuite), } @@ -1270,15 +1289,23 @@ pub mod release_acceptance { match self { Self::EmptyLimitationClaim => formatter .write_str("declared release limitation must name an unsupported claim"), + Self::LimitationClaimTooLong => formatter + .write_str("declared release limitation claim exceeds the byte budget"), Self::InvalidLimitationClaim => formatter.write_str( "declared release limitation claim contains an unsafe presentation character", ), Self::EmptyLimitationConsequence => formatter.write_str( "declared release limitation must state a buyer-visible consequence", ), + Self::LimitationConsequenceTooLong => formatter.write_str( + "declared release limitation consequence exceeds the byte budget", + ), Self::InvalidLimitationConsequence => formatter.write_str( "declared release limitation consequence contains an unsafe presentation character", ), + Self::TooManyDeclaredLimitations => formatter.write_str( + "benchmark release decision contains too many declared limitations", + ), Self::DuplicateSuite(suite) => write!( formatter, "benchmark release evidence contains duplicate suite: {}", @@ -1334,13 +1361,14 @@ pub mod release_acceptance { /// Produce one deterministic release decision from mandatory suite outcomes. /// - /// Duplicate suite evidence fails closed rather than selecting an arbitrary - /// result. A known mandatory-threshold failure is always rejected, even when - /// other suites are missing or inconclusive; all such evidence gaps remain in - /// the returned report. Without a known failure, missing or inconclusive - /// evidence is never promoted to acceptance. Accepted-with-limitations requires - /// at least one validated [`DeclaredLimitation`], so the decision cannot be - /// detached from the exact narrowed claim and buyer-visible consequence. + /// Duplicate suite evidence and excessive declared-limitation cardinality fail + /// closed rather than selecting or retaining an attacker-controlled unbounded set. + /// A known mandatory-threshold failure is always rejected, even when other suites + /// are missing or inconclusive; all such evidence gaps remain in the returned + /// report. Without a known failure, missing or inconclusive evidence is never + /// promoted to acceptance. Accepted-with-limitations requires at least one + /// validated [`DeclaredLimitation`], so the decision cannot be detached from the + /// exact narrowed claim and buyer-visible consequence. pub fn decide_release( results: I, declared_limitations: &[DeclaredLimitation], @@ -1348,6 +1376,10 @@ pub mod release_acceptance { where I: IntoIterator, { + if declared_limitations.len() > MAX_DECLARED_RELEASE_LIMITATIONS { + return Err(ReleaseDecisionError::TooManyDeclaredLimitations); + } + let mut outcomes = [None; BenchmarkSuite::ALL.len()]; for (suite, outcome) in results { let slot = &mut outcomes[suite.index()]; From 913195138585c865f21ff7a7c0b5fe3ac13cafc5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 11:13:31 -0700 Subject: [PATCH 343/570] docs: record release limitation resource bounds --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb25c0f4..ba05d3e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Bounded each buyer-visible release-limitation field to 1,024 UTF-8 bytes and each release report to 64 declared limitations, rejecting oversize metadata and excessive cardinality before the report clones retained limitation state. - 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. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. From 62ef5ef27e6c46d04d37c7dc5188d8d6e233ff79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 11:24:33 -0700 Subject: [PATCH 344/570] test(core): cover release decision vector branches --- .../release_acceptance_resource_bounds.rs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs index 94729086a..3bd6b4f5d 100644 --- a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs +++ b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs @@ -74,6 +74,56 @@ fn release_report_bounds_declared_limitation_count_before_cloning() Ok(()) } +#[test] +fn resource_bounds_vector_iterator_preserves_every_release_decision_branch() +-> Result<(), ReleaseDecisionError> { + assert_eq!( + decide_release(passing_results(), &[])?.decision(), + ReleaseDecision::Accepted + ); + + let mut failed = passing_results(); + failed[0].1 = BenchmarkSuiteOutcome::Failed; + assert_eq!( + decide_release(failed, &[])?.decision(), + ReleaseDecision::Rejected + ); + + let mut inconclusive = passing_results(); + inconclusive[0].1 = BenchmarkSuiteOutcome::Inconclusive; + assert_eq!( + decide_release(inconclusive, &[])?.decision(), + ReleaseDecision::Inconclusive + ); + + let mut missing = passing_results(); + assert!(missing.pop().is_some()); + assert_eq!( + decide_release(missing, &[])?.decision(), + ReleaseDecision::Inconclusive + ); + + assert_eq!( + decide_release( + vec![ + ( + BenchmarkSuite::ControlledDeterministic, + BenchmarkSuiteOutcome::Passed, + ), + ( + BenchmarkSuite::ControlledDeterministic, + BenchmarkSuiteOutcome::Passed, + ), + ], + &[], + ), + Err(ReleaseDecisionError::DuplicateSuite( + BenchmarkSuite::ControlledDeterministic + )) + ); + Ok(()) +} + #[test] fn release_resource_limit_errors_have_deterministic_standard_error_contracts() { let cases = [ From b31ed013713d4d709ee1ecb4f499d229fc3da642 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 11:30:58 -0700 Subject: [PATCH 345/570] test(core): align release bound inputs for exact branch evidence --- .../release_acceptance_resource_bounds.rs | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs index 3bd6b4f5d..373f76562 100644 --- a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs +++ b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs @@ -14,22 +14,23 @@ fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { fn limitation_metadata_enforces_exact_utf8_byte_budget() -> Result<(), ReleaseDecisionError> { let maximum_claim = "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); let maximum_consequence = "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); - let limitation = DeclaredLimitation::new(maximum_claim.clone(), maximum_consequence.clone())?; + let limitation = DeclaredLimitation::new( + maximum_claim.as_str(), + maximum_consequence.as_str(), + )?; assert_eq!(limitation.unsupported_claim(), maximum_claim.as_str()); assert_eq!(limitation.buyer_consequence(), maximum_consequence.as_str()); + + let oversized_claim = "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1); assert_eq!( - DeclaredLimitation::new( - "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1), - "bounded buyer consequence" - ), + DeclaredLimitation::new(oversized_claim.as_str(), "bounded buyer consequence"), Err(ReleaseDecisionError::LimitationClaimTooLong) ); + + let oversized_consequence = "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1); assert_eq!( - DeclaredLimitation::new( - "bounded_claim", - "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1) - ), + DeclaredLimitation::new("bounded_claim", oversized_consequence.as_str()), Err(ReleaseDecisionError::LimitationConsequenceTooLong) ); Ok(()) @@ -42,7 +43,7 @@ fn limitation_byte_budget_applies_to_international_text() { korean_character.repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES / korean_character.len() + 1); assert!(repeated.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES); assert_eq!( - DeclaredLimitation::new(repeated, "지원 범위를 설명하는 구매자 안내"), + DeclaredLimitation::new(repeated.as_str(), "지원 범위를 설명하는 구매자 안내"), Err(ReleaseDecisionError::LimitationClaimTooLong) ); } From 89745f2e72a26e51485ea50076438d36ae6b0325 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 11:35:55 -0700 Subject: [PATCH 346/570] test(core): consolidate release decision coverage owner --- .../tests/release_acceptance.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index a2da1c666..0b6f0cd95 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -313,3 +313,33 @@ fn decision_is_independent_of_evidence_input_order() { decide_release(passing_results(), &[]) ); } + +#[test] +fn release_report_bounds_declared_limitation_count_before_cloning() +-> Result<(), ReleaseDecisionError> { + let limitation = declared_limitation()?; + let maximum = vec![ + limitation.clone(); + originweave_core::release_acceptance::MAX_DECLARED_RELEASE_LIMITATIONS + ]; + let report = decide_release(passing_results(), &maximum)?; + + assert_eq!( + report.decision(), + ReleaseDecision::AcceptedWithDeclaredLimitations + ); + assert_eq!( + report.declared_limitations().len(), + originweave_core::release_acceptance::MAX_DECLARED_RELEASE_LIMITATIONS + ); + + let too_many = vec![ + limitation; + originweave_core::release_acceptance::MAX_DECLARED_RELEASE_LIMITATIONS + 1 + ]; + assert_eq!( + decide_release(passing_results(), &too_many), + Err(ReleaseDecisionError::TooManyDeclaredLimitations) + ); + Ok(()) +} From 52fcda0b69f70077da9e74ff56d57cd8c97f6d3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 11:36:11 -0700 Subject: [PATCH 347/570] test(core): remove duplicate release decision monomorph --- .../release_acceptance_resource_bounds.rs | 92 +------------------ 1 file changed, 2 insertions(+), 90 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs index 373f76562..e4d655557 100644 --- a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs +++ b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs @@ -1,23 +1,12 @@ use originweave_core::release_acceptance::{ - BenchmarkSuite, BenchmarkSuiteOutcome, DeclaredLimitation, MAX_DECLARED_RELEASE_LIMITATIONS, - MAX_RELEASE_LIMITATION_TEXT_BYTES, ReleaseDecision, ReleaseDecisionError, decide_release, + DeclaredLimitation, MAX_RELEASE_LIMITATION_TEXT_BYTES, ReleaseDecisionError, }; -fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { - BenchmarkSuite::ALL - .into_iter() - .map(|suite| (suite, BenchmarkSuiteOutcome::Passed)) - .collect() -} - #[test] fn limitation_metadata_enforces_exact_utf8_byte_budget() -> Result<(), ReleaseDecisionError> { let maximum_claim = "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); let maximum_consequence = "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); - let limitation = DeclaredLimitation::new( - maximum_claim.as_str(), - maximum_consequence.as_str(), - )?; + let limitation = DeclaredLimitation::new(maximum_claim.as_str(), maximum_consequence.as_str())?; assert_eq!(limitation.unsupported_claim(), maximum_claim.as_str()); assert_eq!(limitation.buyer_consequence(), maximum_consequence.as_str()); @@ -48,83 +37,6 @@ fn limitation_byte_budget_applies_to_international_text() { ); } -#[test] -fn release_report_bounds_declared_limitation_count_before_cloning() --> Result<(), ReleaseDecisionError> { - let limitation = DeclaredLimitation::new( - "linux_arm64", - "Linux ARM64 is outside the declared support profile.", - )?; - let maximum = vec![limitation.clone(); MAX_DECLARED_RELEASE_LIMITATIONS]; - let report = decide_release(passing_results(), &maximum)?; - - assert_eq!( - report.decision(), - ReleaseDecision::AcceptedWithDeclaredLimitations - ); - assert_eq!( - report.declared_limitations().len(), - MAX_DECLARED_RELEASE_LIMITATIONS - ); - - let too_many = vec![limitation; MAX_DECLARED_RELEASE_LIMITATIONS + 1]; - assert_eq!( - decide_release(passing_results(), &too_many), - Err(ReleaseDecisionError::TooManyDeclaredLimitations) - ); - Ok(()) -} - -#[test] -fn resource_bounds_vector_iterator_preserves_every_release_decision_branch() --> Result<(), ReleaseDecisionError> { - assert_eq!( - decide_release(passing_results(), &[])?.decision(), - ReleaseDecision::Accepted - ); - - let mut failed = passing_results(); - failed[0].1 = BenchmarkSuiteOutcome::Failed; - assert_eq!( - decide_release(failed, &[])?.decision(), - ReleaseDecision::Rejected - ); - - let mut inconclusive = passing_results(); - inconclusive[0].1 = BenchmarkSuiteOutcome::Inconclusive; - assert_eq!( - decide_release(inconclusive, &[])?.decision(), - ReleaseDecision::Inconclusive - ); - - let mut missing = passing_results(); - assert!(missing.pop().is_some()); - assert_eq!( - decide_release(missing, &[])?.decision(), - ReleaseDecision::Inconclusive - ); - - assert_eq!( - decide_release( - vec![ - ( - BenchmarkSuite::ControlledDeterministic, - BenchmarkSuiteOutcome::Passed, - ), - ( - BenchmarkSuite::ControlledDeterministic, - BenchmarkSuiteOutcome::Passed, - ), - ], - &[], - ), - Err(ReleaseDecisionError::DuplicateSuite( - BenchmarkSuite::ControlledDeterministic - )) - ); - Ok(()) -} - #[test] fn release_resource_limit_errors_have_deterministic_standard_error_contracts() { let cases = [ From 71c18e7d3713cfea436e2bc960e130cff155ea70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 11:40:12 -0700 Subject: [PATCH 348/570] test(core): unify release decision iterator coverage --- crates/originweave-core/tests/release_acceptance.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index 0b6f0cd95..0f2422cad 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -231,7 +231,7 @@ fn any_known_threshold_failure_rejects_release_and_identifies_the_suite() fn known_failure_remains_rejected_when_other_evidence_is_incomplete() -> Result<(), ReleaseDecisionError> { let report = decide_release( - [ + vec![ ( BenchmarkSuite::ControlledDeterministic, BenchmarkSuiteOutcome::Failed, @@ -270,7 +270,7 @@ fn duplicate_suite_evidence_fails_closed_instead_of_overwriting_results() { let expected_error = ReleaseDecisionError::DuplicateSuite(duplicate_suite); assert_eq!( decide_release( - [ + vec![ (duplicate_suite, BenchmarkSuiteOutcome::Passed), (duplicate_suite, BenchmarkSuiteOutcome::Failed), ], From 73bca937423311867fb9de31a797121063f077bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 12:32:50 -0700 Subject: [PATCH 349/570] test(core): cover borrowed limitation validation exits --- .../release_acceptance_resource_bounds.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs index e4d655557..489f2230b 100644 --- a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs +++ b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs @@ -25,6 +25,26 @@ fn limitation_metadata_enforces_exact_utf8_byte_budget() -> Result<(), ReleaseDe Ok(()) } +#[test] +fn borrowed_limitation_text_covers_every_validation_exit() { + assert_eq!( + DeclaredLimitation::new("", "bounded buyer consequence"), + Err(ReleaseDecisionError::EmptyLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", ""), + Err(ReleaseDecisionError::EmptyLimitationConsequence) + ); + assert_eq!( + DeclaredLimitation::new("forged\nclaim", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "forged\nconsequence"), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); +} + #[test] fn limitation_byte_budget_applies_to_international_text() { let korean_character = "가"; From 6fc9a9f8d48f3ce31f8e8bac1700a4f30c04f71d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 12:38:10 -0700 Subject: [PATCH 350/570] test(core): exhaust Unicode 17 ignorable ranges --- .../tests/release_acceptance_unicode17.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance_unicode17.rs b/crates/originweave-core/tests/release_acceptance_unicode17.rs index f2bd5b020..71f8a99cb 100644 --- a/crates/originweave-core/tests/release_acceptance_unicode17.rs +++ b/crates/originweave-core/tests/release_acceptance_unicode17.rs @@ -1,9 +1,11 @@ use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; +const UNICODE_17_DEFAULT_IGNORABLE_CODE_POINT_COUNT: usize = 4_174; + #[test] fn limitation_rejects_unicode_17_default_ignorable_code_points() -> Result<(), &'static str> { - // Unicode 17.0.0 DerivedCoreProperties.txt, Default_Ignorable_Code_Point. - // Endpoints plus a midpoint make every reviewed inclusive range executable evidence. + // Unicode 17.0.0 DerivedCoreProperties.txt (2025-07-30), + // Default_Ignorable_Code_Point. The reviewed ranges contain exactly 4,174 code points. let ranges = [ (0x00ad_u32, 0x00ad_u32), (0x034f, 0x034f), @@ -23,12 +25,13 @@ fn limitation_rejects_unicode_17_default_ignorable_code_points() -> Result<(), & (0x1d173, 0x1d17a), (0xe0000, 0xe0fff), ]; + let mut tested_code_points = 0_usize; for (start, end) in ranges { - let midpoint = start + (end - start) / 2; - for code_point in [start, midpoint, end] { + for code_point in start..=end { let character = char::from_u32(code_point) .ok_or("reviewed Unicode 17 default-ignorable range must contain scalar values")?; + tested_code_points += 1; assert_eq!( DeclaredLimitation::new( @@ -49,6 +52,10 @@ fn limitation_rejects_unicode_17_default_ignorable_code_points() -> Result<(), & } } + assert_eq!( + tested_code_points, UNICODE_17_DEFAULT_IGNORABLE_CODE_POINT_COUNT, + "reviewed Unicode 17 Default_Ignorable_Code_Point ranges must match the authoritative cardinality", + ); Ok(()) } From 02b43e971dc4e49c992488c365d4e955747faeed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 12:41:16 -0700 Subject: [PATCH 351/570] test(network): reject reserved WebSocket close code 1004 --- .../tests/webdriver_bidi_websocket_close_frame_validation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs index c296977cb..38f819f5b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_close_frame_validation.rs @@ -76,7 +76,7 @@ fn close_frame_enforces_payload_shape_and_utf8_reason() -> Result<(), Box Result<(), Box> { - for status_code in [999_u16, 1005, 1006, 1015, 5000] { + for status_code in [999_u16, 1004, 1005, 1006, 1015, 5000] { let [high, low] = status_code.to_be_bytes(); assert!(matches!( exchange_server_frame(&[0x88, 0x02, high, low])?, From 11faa7fbbdf3c8a924ff3a544d915c0a318da285 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 12:43:48 -0700 Subject: [PATCH 352/570] fix(network): reject reserved WebSocket close code 1004 --- .../src/webdriver_bidi_websocket_validated.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index d32eab98d..1cfc4a888 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -210,7 +210,9 @@ fn validate_close_status_code( } let status_code = u16::from_be_bytes([frame.payload()[0], frame.payload()[1]]); - if !(1000..=4999).contains(&status_code) || matches!(status_code, 1005 | 1006 | 1015) { + if !(1000..=4999).contains(&status_code) + || matches!(status_code, 1004 | 1005 | 1006 | 1015) + { return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "Close frame status code is not valid on the wire", }); From e9dda451d7411ce5869415cf0866069efbdde359 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 12:47:00 -0700 Subject: [PATCH 353/570] style(network): apply canonical WebSocket close formatting --- .../src/webdriver_bidi_websocket_validated.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index 1cfc4a888..d6e85f064 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -210,9 +210,7 @@ fn validate_close_status_code( } let status_code = u16::from_be_bytes([frame.payload()[0], frame.payload()[1]]); - if !(1000..=4999).contains(&status_code) - || matches!(status_code, 1004 | 1005 | 1006 | 1015) - { + if !(1000..=4999).contains(&status_code) || matches!(status_code, 1004 | 1005 | 1006 | 1015) { return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: "Close frame status code is not valid on the wire", }); From eae0eec77f343ecdf6413247da515a48583e9140 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 14:14:01 -0700 Subject: [PATCH 354/570] docs: clarify conservative Unicode release metadata --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba05d3e52..2008a60c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,7 +47,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security -- Release-acceptance limitation metadata rejects embedded controls, U+2028/U+2029 line and paragraph separators, and Unicode 17.0.0 `Default_Ignorable_Code_Point` characters while preserving ordinary international text, so buyer-visible narrowed claims and consequences cannot forge or invisibly alter release statements. +- Release-acceptance limitation metadata rejects embedded controls, U+2028/U+2029 line and paragraph separators, and Unicode 17.0.0 `Default_Ignorable_Code_Point` characters as a deliberately conservative high-assurance metadata profile. This prevents invisible or presentation-dependent release claims, but intentionally does not promise unrestricted natural-language typography: Unicode 17 documents legitimate orthographic uses for U+200C ZWNJ and U+200D ZWJ, so text requiring join controls is outside this bounded metadata profile. - Raw page content cannot become a trusted instruction. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. @@ -77,4 +77,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file From 36de84d2f08b1ae2bdbe5992c941834f2240b80c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:09:05 -0700 Subject: [PATCH 355/570] test(evidence): reject contradictory field cardinality --- .../tests/extraction_schema.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/originweave-evidence/tests/extraction_schema.rs b/crates/originweave-evidence/tests/extraction_schema.rs index cbb89955e..7e932244b 100644 --- a/crates/originweave-evidence/tests/extraction_schema.rs +++ b/crates/originweave-evidence/tests/extraction_schema.rs @@ -140,6 +140,26 @@ fn field_accepts_all_reviewed_value_and_source_channel_variants() Ok(()) } +#[test] +fn field_rejects_contradictory_required_cardinality_contracts() { + assert!(ExtractionField::new( + "optional_exactly_one", + ExtractionValueType::Text, + ExtractionCardinality::One, + false, + &[ExtractionSourceChannel::SemanticNode], + ) + .is_err()); + assert!(ExtractionField::new( + "required_zero_or_one", + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + true, + &[ExtractionSourceChannel::SemanticNode], + ) + .is_err()); +} + #[test] fn field_rejects_empty_malformed_or_overlong_identifiers() { assert_eq!( From c5a87a64d96b12ef0399b72cb7d0ede3bb7e33d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:10:09 -0700 Subject: [PATCH 356/570] style(evidence): format cardinality regression --- .../tests/extraction_schema.rs | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/crates/originweave-evidence/tests/extraction_schema.rs b/crates/originweave-evidence/tests/extraction_schema.rs index 7e932244b..6cc7af539 100644 --- a/crates/originweave-evidence/tests/extraction_schema.rs +++ b/crates/originweave-evidence/tests/extraction_schema.rs @@ -142,22 +142,26 @@ fn field_accepts_all_reviewed_value_and_source_channel_variants() #[test] fn field_rejects_contradictory_required_cardinality_contracts() { - assert!(ExtractionField::new( - "optional_exactly_one", - ExtractionValueType::Text, - ExtractionCardinality::One, - false, - &[ExtractionSourceChannel::SemanticNode], - ) - .is_err()); - assert!(ExtractionField::new( - "required_zero_or_one", - ExtractionValueType::Text, - ExtractionCardinality::ZeroOrOne, - true, - &[ExtractionSourceChannel::SemanticNode], - ) - .is_err()); + assert!( + ExtractionField::new( + "optional_exactly_one", + ExtractionValueType::Text, + ExtractionCardinality::One, + false, + &[ExtractionSourceChannel::SemanticNode], + ) + .is_err() + ); + assert!( + ExtractionField::new( + "required_zero_or_one", + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + true, + &[ExtractionSourceChannel::SemanticNode], + ) + .is_err() + ); } #[test] From 212d48d3a673389eb77544c1d673452e2ff6afc7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:12:28 -0700 Subject: [PATCH 357/570] fix(evidence): reject contradictory field cardinality --- .../originweave-evidence/src/extraction_schema.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/originweave-evidence/src/extraction_schema.rs b/crates/originweave-evidence/src/extraction_schema.rs index ca2ba4881..14a86a24c 100644 --- a/crates/originweave-evidence/src/extraction_schema.rs +++ b/crates/originweave-evidence/src/extraction_schema.rs @@ -71,6 +71,8 @@ pub enum ExtractionSchemaError { InvalidIdentifier, /// An identifier or field collection exceeded its bounded limit. LimitExceeded, + /// A field's required flag contradicted its declared cardinality. + InvalidCardinalityRequirement, /// A field did not declare any reviewed source channel. MissingSourceChannel, /// A field declared the same source channel more than once. @@ -88,6 +90,9 @@ impl fmt::Display for ExtractionSchemaError { formatter.write_str(match self { Self::InvalidIdentifier => "invalid extraction schema or field identifier", Self::LimitExceeded => "extraction schema limit exceeded", + Self::InvalidCardinalityRequirement => { + "extraction field required flag is incompatible with the declared cardinality" + } Self::MissingSourceChannel => "extraction field requires at least one source channel", Self::DuplicateSourceChannel => "extraction field contains a duplicate source channel", Self::InvalidNormalizationRule => { @@ -141,6 +146,16 @@ impl ExtractionField { source_channels: &[ExtractionSourceChannel], ) -> Result { validate_identifier(identifier)?; + + let cardinality_requirement_is_compatible = match cardinality { + ExtractionCardinality::One => required, + ExtractionCardinality::ZeroOrOne => !required, + ExtractionCardinality::Many => true, + }; + if !cardinality_requirement_is_compatible { + return Err(ExtractionSchemaError::InvalidCardinalityRequirement); + } + if source_channels.is_empty() { return Err(ExtractionSchemaError::MissingSourceChannel); } From 67545c69a2c19dcd73871e1db6c8d3f72e7aca83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:12:55 -0700 Subject: [PATCH 358/570] test(evidence): bind cardinality failure type --- .../tests/extraction_schema.rs | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/originweave-evidence/tests/extraction_schema.rs b/crates/originweave-evidence/tests/extraction_schema.rs index 6cc7af539..fc875ef0f 100644 --- a/crates/originweave-evidence/tests/extraction_schema.rs +++ b/crates/originweave-evidence/tests/extraction_schema.rs @@ -137,30 +137,39 @@ fn field_accepts_all_reviewed_value_and_source_channel_variants() assert_eq!(field.cardinality(), ExtractionCardinality::Many); assert_eq!(field.source_channels(), &[source_channel]); } + + let required_many = field( + "required_many", + ExtractionValueType::Text, + ExtractionCardinality::Many, + true, + &[ExtractionSourceChannel::SemanticNode], + )?; + assert!(required_many.required()); Ok(()) } #[test] fn field_rejects_contradictory_required_cardinality_contracts() { - assert!( + assert_eq!( ExtractionField::new( "optional_exactly_one", ExtractionValueType::Text, ExtractionCardinality::One, false, &[ExtractionSourceChannel::SemanticNode], - ) - .is_err() + ), + Err(ExtractionSchemaError::InvalidCardinalityRequirement) ); - assert!( + assert_eq!( ExtractionField::new( "required_zero_or_one", ExtractionValueType::Text, ExtractionCardinality::ZeroOrOne, true, &[ExtractionSourceChannel::SemanticNode], - ) - .is_err() + ), + Err(ExtractionSchemaError::InvalidCardinalityRequirement) ); } From f2e6f2d4248d41d8b65c85d5ff1a6dfb53ea9006 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:13:06 -0700 Subject: [PATCH 359/570] test(evidence): cover cardinality error contract --- .../tests/extraction_schema_error_contract.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/originweave-evidence/tests/extraction_schema_error_contract.rs b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs index 1ba248a1d..b4897d90f 100644 --- a/crates/originweave-evidence/tests/extraction_schema_error_contract.rs +++ b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs @@ -17,6 +17,10 @@ fn extraction_schema_errors_implement_standard_error_contract() { ExtractionSchemaError::LimitExceeded, "extraction schema limit exceeded", ), + ( + ExtractionSchemaError::InvalidCardinalityRequirement, + "extraction field required flag is incompatible with the declared cardinality", + ), ( ExtractionSchemaError::MissingSourceChannel, "extraction field requires at least one source channel", From c38b9665774d6b3754e572bed527737b5e179833 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:13:41 -0700 Subject: [PATCH 360/570] docs(evidence): bind cardinality presence semantics --- docs/adr/0106-provenance-evidence-model.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0106-provenance-evidence-model.md b/docs/adr/0106-provenance-evidence-model.md index 60dbb929c..09cb0d7ca 100644 --- a/docs/adr/0106-provenance-evidence-model.md +++ b/docs/adr/0106-provenance-evidence-model.md @@ -37,7 +37,7 @@ WARC and PROV are interoperability/export contracts, not substitutes for OriginW A versioned `ExtractionSchema` is the binding contract for typed extraction before any capture persistence or export format is allowed to claim semantic authority. Each schema version contains an ordered, non-empty set of unique `ExtractionField` definitions. Schema-version and field identifiers are bounded to 128 encoded bytes, begin with a lowercase ASCII letter, and thereafter admit only lowercase ASCII letters, digits, `_`, or `-`. One schema admits at most 256 fields. -Every extraction field binds its stable identifier to a value type, cardinality, required/optional status, deterministic normalization rule, and a non-empty duplicate-free set of reviewed source-channel classes. `Verbatim` is the compatibility default used by the existing constructor. `TrimTextWhitespace` is admitted only for text fields and `Rfc3339Utc` only for timestamp fields; type-incompatible normalization fails closed. A `ModelInterpretation` source channel is classification metadata only and does not grant model execution, approval, disclosure, browser, network, secret, or storage authority. +Every extraction field binds its stable identifier to a value type, cardinality, required/optional status, deterministic normalization rule, and a non-empty duplicate-free set of reviewed source-channel classes. Cardinality and required status form one internally consistent presence contract: `One` is necessarily required, `ZeroOrOne` is necessarily optional, and `Many` may be marked required or optional because this value-object layer does not yet define a minimum collection item count. Contradictory `One`/optional or `ZeroOrOne`/required declarations fail closed during field construction. `Verbatim` is the compatibility default used by the existing constructor. `TrimTextWhitespace` is admitted only for text fields and `Rfc3339Utc` only for timestamp fields; type-incompatible normalization fails closed. A `ModelInterpretation` source channel is classification metadata only and does not grant model execution, approval, disclosure, browser, network, secret, or storage authority. At this value-object boundary, the version identifier is immutable schema identity; there is deliberately no registry that silently treats two different field contracts as compatible merely because their version strings compare or sort in a particular way. Callers changing a field identifier, value type, cardinality, required status, normalization rule, or admitted source-channel set must use a distinct reviewed schema version and perform any migration/compatibility decision at an explicit higher layer. The current schema object does not itself read browser data, materialize extracted values, persist artifacts, execute models, or change governance policy. Those capabilities require separately authorized runtime boundaries and are not implied by schema construction. @@ -51,7 +51,7 @@ A schema consumer can also determine the exact field/type/cardinality/normalizat If mandatory evidence cannot be recorded durably enough for a governed state-changing action, the action fails before execution or reports an explicit unverifiable failure; it is never marked proved. Read-only operations may degrade to reduced evidence only when the API contract declares that mode. Corrupt or incomplete evidence is quarantined rather than silently accepted. -Invalid or oversized extraction identifiers, empty or duplicate field sets, missing or duplicate source channels, and type-incompatible normalization rules fail during schema construction. A caller must not reinterpret such a failure as an empty/default-success schema or silently substitute another source channel. +Invalid or oversized extraction identifiers, contradictory cardinality/required declarations, empty or duplicate field sets, missing or duplicate source channels, and type-incompatible normalization rules fail during schema construction. A caller must not reinterpret such a failure as an empty/default-success schema or silently substitute another source channel. ## Security / privacy / governance impact @@ -63,7 +63,7 @@ The extraction-schema contract does not modify governance authority. It describe Require provenance-link tests, credential-leak tests, integrity/corruption tests, crash-recovery tests, WARC/export conformance where implemented, PROV relation/schema tests where implemented, retention/deletion tests, tenant-isolation tests, and end-to-end checks that state-changing actions link request, policy, approval, execution, and post-condition as separate records. Export tests must prove that disabled or unauthorized source bodies never appear merely because metadata provenance is exportable. -The extraction-schema boundary additionally requires tests for the identifier grammar and limits, field-count bound, duplicate identifiers, source-channel presence and uniqueness, every reviewed value/cardinality/source-channel variant, deterministic normalization selection, incompatible normalization rejection, and the backward-compatible `Verbatim` constructor default. +The extraction-schema boundary additionally requires tests for the identifier grammar and limits, field-count bound, duplicate identifiers, source-channel presence and uniqueness, every reviewed value/cardinality/source-channel variant, consistent cardinality/required combinations and contradictory-combination rejection, deterministic normalization selection, incompatible normalization rejection, and the backward-compatible `Verbatim` constructor default. ## Migration and rollback From 2feb778fb09962acddd7e4f6b357b454d920e737 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 10:36:17 -0700 Subject: [PATCH 361/570] test(bap): use typed lifecycle setup states --- .../originweave-bap/tests/task_lifecycle.rs | 54 ++++++++++++------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/crates/originweave-bap/tests/task_lifecycle.rs b/crates/originweave-bap/tests/task_lifecycle.rs index a2c58822b..01013682a 100644 --- a/crates/originweave-bap/tests/task_lifecycle.rs +++ b/crates/originweave-bap/tests/task_lifecycle.rs @@ -113,16 +113,17 @@ fn terminal_task_never_reopens_or_advances_history() { #[test] fn cancellation_and_expiry_cover_pre_dispatch_and_suspended_states() { - for (state, setup) in [ - (BapTaskState::Created, 0_u8), - (BapTaskState::Admitted, 1), - (BapTaskState::Running, 2), - (BapTaskState::WaitingForApproval, 3), - (BapTaskState::WaitingForExternalInput, 4), - (BapTaskState::Checkpointed, 5), + for state in [ + BapTaskState::Created, + BapTaskState::Admitted, + BapTaskState::Running, + BapTaskState::WaitingForApproval, + BapTaskState::WaitingForExternalInput, + BapTaskState::Checkpointed, + BapTaskState::ReconciliationRequired, ] { for terminal_event in [BapTaskEvent::Cancel, BapTaskEvent::Expire] { - let mut task = task_in_state(setup); + let mut task = task_in_state(state); assert_eq!(task.state(), state); task.apply(terminal_event).expect("terminal interruption"); assert!(task.state().is_terminal()); @@ -209,27 +210,44 @@ fn running_task() -> BapTaskLifecycle { task } -fn task_in_state(setup: u8) -> BapTaskLifecycle { +fn task_in_state(target: BapTaskState) -> BapTaskLifecycle { let mut task = BapTaskLifecycle::new(); - if setup >= 1 { - task.apply(BapTaskEvent::Admit).expect("admit"); + if target == BapTaskState::Created { + return task; } - if setup >= 2 { - task.apply(BapTaskEvent::Start).expect("start"); + + task.apply(BapTaskEvent::Admit).expect("admit"); + if target == BapTaskState::Admitted { + return task; } - match setup { - 3 => { + + task.apply(BapTaskEvent::Start).expect("start"); + match target { + BapTaskState::Running => {} + BapTaskState::WaitingForApproval => { task.apply(BapTaskEvent::WaitForApproval) .expect("wait approval"); } - 4 => { + BapTaskState::WaitingForExternalInput => { task.apply(BapTaskEvent::WaitForExternalInput) .expect("wait external"); } - 5 => { + BapTaskState::Checkpointed => { task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); } - _ => {} + BapTaskState::ReconciliationRequired => { + task.apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation"); + } + BapTaskState::Created + | BapTaskState::Admitted + | BapTaskState::Succeeded + | BapTaskState::Failed + | BapTaskState::Cancelled + | BapTaskState::Expired + | BapTaskState::DeadLettered => { + unreachable!("task_in_state only constructs non-terminal lifecycle states") + } } task } From b88e2fb037dd4914c85d5e8f129e5a5671a96426 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 10:42:51 -0700 Subject: [PATCH 362/570] docs(adr): define post-integration provenance handling --- docs/adr/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/adr/README.md b/docs/adr/README.md index f9d2dfa0e..5f9e2a878 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -65,6 +65,8 @@ ADR 0013 and ADR 0014 exist only on this documentation branch until it integrate ADR 0016 belongs to the active BAP lifecycle feature branch. Indexing it makes the branch documentation graph complete while preserving its Proposed lifecycle and 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 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. ## Index completeness rule From d71b05f43b0e96a7bb542d0329a8d6d6ab580030 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 10:43:25 -0700 Subject: [PATCH 363/570] docs: synchronize ADR 0016 provenance lifecycle --- docs/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index ec7c5702e..3f837b5ac 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,7 +44,7 @@ The PRD/TRD/Architecture/ADR/UML/ERD/data-governance/traceability/security/opera - [Safety-kernel implementation plan](superpowers/plans/2026-08-05-agent-safety-kernel.md) - [Resolved-destination policy design](superpowers/specs/2026-08-06-resolved-destination-policy-design.md) - [Resolved-destination policy implementation plan](superpowers/plans/2026-08-06-resolved-destination-policy.md) -- [Direct socket binding design](superpowers/specs/2026-08-06-direct-socket-binding-design.md) +- [Direct socket binding design](superpowers/specs/2026-08-06-direct-socket-binding.md) - [Direct socket binding implementation plan](superpowers/plans/2026-08-06-direct-socket-binding.md) - [TLS service-identity design](superpowers/specs/2026-08-06-tls-server-identity-design.md) - [TLS service-identity implementation plan](superpowers/plans/2026-08-06-tls-server-identity.md) @@ -92,4 +92,6 @@ The second group exists only on this documentation branch until the branch integ ADR 0016 is owned by this active BAP lifecycle feature branch and remains Proposed. Its presence here makes the branch documentation graph complete without presenting the decision or implementation as protected-main truth before integration. +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 from Proposed or assert implementation maturity. + See the [ADR index](adr/README.md) for status rules, required decision structure, supersession rules, and active feature ADRs. The index and each ADR's own status metadata must agree; a PR body, chat transcript, automation prompt, or stale issue reference cannot change ADR status. From 85cc477688246900697f4cfb91c0c8f1f692934a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 10:51:36 -0700 Subject: [PATCH 364/570] docs: repair direct socket design link --- docs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index 3f837b5ac..05812194a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,7 +44,7 @@ The PRD/TRD/Architecture/ADR/UML/ERD/data-governance/traceability/security/opera - [Safety-kernel implementation plan](superpowers/plans/2026-08-05-agent-safety-kernel.md) - [Resolved-destination policy design](superpowers/specs/2026-08-06-resolved-destination-policy-design.md) - [Resolved-destination policy implementation plan](superpowers/plans/2026-08-06-resolved-destination-policy.md) -- [Direct socket binding design](superpowers/specs/2026-08-06-direct-socket-binding.md) +- [Direct socket binding design](superpowers/specs/2026-08-06-direct-socket-binding-design.md) - [Direct socket binding implementation plan](superpowers/plans/2026-08-06-direct-socket-binding.md) - [TLS service-identity design](superpowers/specs/2026-08-06-tls-server-identity-design.md) - [TLS service-identity implementation plan](superpowers/plans/2026-08-06-tls-server-identity.md) From 955d24b8a3eca9819e219932dfad3aaece61707f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 17:08:05 -0700 Subject: [PATCH 365/570] test(core): reject conflicting release limitation claims --- .../tests/release_acceptance.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index 0f2422cad..957d7ca49 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -314,6 +314,25 @@ fn decision_is_independent_of_evidence_input_order() { ); } +#[test] +fn conflicting_consequences_for_one_limitation_claim_fail_closed() +-> Result<(), ReleaseDecisionError> { + let first = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is excluded from the support profile.", + )?; + let conflicting = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is supported only for evaluation deployments.", + )?; + + assert!( + decide_release(passing_results(), &[first, conflicting]).is_err(), + "one unsupported claim must not retain contradictory buyer consequences" + ); + Ok(()) +} + #[test] fn release_report_bounds_declared_limitation_count_before_cloning() -> Result<(), ReleaseDecisionError> { From b71d6ba3948b799155d177874ab00fa8e2a9d526 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 17:12:30 -0700 Subject: [PATCH 366/570] fix(core): reject duplicate release limitation claims --- crates/originweave-core/src/lib.rs | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index efa4aac5d..a208546b6 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1280,6 +1280,8 @@ pub mod release_acceptance { InvalidLimitationConsequence, /// One release report supplied more buyer-visible limitations than the fixed resource budget. TooManyDeclaredLimitations, + /// More than one limitation used the same unsupported claim identity. + DuplicateLimitationClaim, /// The same suite appeared more than once instead of one authoritative result. DuplicateSuite(BenchmarkSuite), } @@ -1306,6 +1308,9 @@ pub mod release_acceptance { Self::TooManyDeclaredLimitations => formatter.write_str( "benchmark release decision contains too many declared limitations", ), + Self::DuplicateLimitationClaim => formatter.write_str( + "benchmark release decision contains duplicate limitation claim", + ), Self::DuplicateSuite(suite) => write!( formatter, "benchmark release evidence contains duplicate suite: {}", @@ -1361,14 +1366,15 @@ pub mod release_acceptance { /// Produce one deterministic release decision from mandatory suite outcomes. /// - /// Duplicate suite evidence and excessive declared-limitation cardinality fail - /// closed rather than selecting or retaining an attacker-controlled unbounded set. - /// A known mandatory-threshold failure is always rejected, even when other suites - /// are missing or inconclusive; all such evidence gaps remain in the returned - /// report. Without a known failure, missing or inconclusive evidence is never - /// promoted to acceptance. Accepted-with-limitations requires at least one - /// validated [`DeclaredLimitation`], so the decision cannot be detached from the - /// exact narrowed claim and buyer-visible consequence. + /// Duplicate suite evidence, duplicate buyer-visible limitation claim identities, + /// and excessive declared-limitation cardinality fail closed rather than selecting + /// or retaining ambiguous or attacker-controlled release metadata. A known + /// mandatory-threshold failure is always rejected, even when other suites are + /// missing or inconclusive; all such evidence gaps remain in the returned report. + /// Without a known failure, missing or inconclusive evidence is never promoted to + /// acceptance. Accepted-with-limitations requires at least one validated + /// [`DeclaredLimitation`], so the decision cannot be detached from the exact + /// narrowed claim and buyer-visible consequence. pub fn decide_release( results: I, declared_limitations: &[DeclaredLimitation], @@ -1380,6 +1386,13 @@ pub mod release_acceptance { return Err(ReleaseDecisionError::TooManyDeclaredLimitations); } + let mut limitation_claims = std::collections::BTreeSet::new(); + for limitation in declared_limitations { + if !limitation_claims.insert(limitation.unsupported_claim()) { + return Err(ReleaseDecisionError::DuplicateLimitationClaim); + } + } + let mut outcomes = [None; BenchmarkSuite::ALL.len()]; for (suite, outcome) in results { let slot = &mut outcomes[suite.index()]; From 5c0478985edccdda33851903282145056ddc2ba6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 17:14:25 -0700 Subject: [PATCH 367/570] test(core): pin duplicate limitation identity contract --- .../tests/release_acceptance.rs | 56 ++++++++++++++----- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index 957d7ca49..dbac815d1 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -1,6 +1,6 @@ use originweave_core::release_acceptance::{ - BenchmarkSuite, BenchmarkSuiteOutcome, DeclaredLimitation, ReleaseDecision, - ReleaseDecisionError, decide_release, + BenchmarkSuite, BenchmarkSuiteOutcome, DeclaredLimitation, MAX_DECLARED_RELEASE_LIMITATIONS, + ReleaseDecision, ReleaseDecisionError, decide_release, }; fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { @@ -138,6 +138,10 @@ fn limitation_errors_have_deterministic_standard_error_contracts() { ReleaseDecisionError::InvalidLimitationConsequence, "declared release limitation consequence contains an unsafe presentation character", ), + ( + ReleaseDecisionError::DuplicateLimitationClaim, + "benchmark release decision contains duplicate limitation claim", + ), ]; for (error, expected_message) in cases { @@ -326,21 +330,39 @@ fn conflicting_consequences_for_one_limitation_claim_fail_closed() "Linux ARM64 is supported only for evaluation deployments.", )?; - assert!( - decide_release(passing_results(), &[first, conflicting]).is_err(), - "one unsupported claim must not retain contradictory buyer consequences" + assert_eq!( + decide_release(passing_results(), &[first, conflicting]), + Err(ReleaseDecisionError::DuplicateLimitationClaim) ); Ok(()) } #[test] -fn release_report_bounds_declared_limitation_count_before_cloning() +fn duplicate_limitation_claim_fails_closed_even_when_consequence_matches() -> Result<(), ReleaseDecisionError> { let limitation = declared_limitation()?; - let maximum = vec![ - limitation.clone(); - originweave_core::release_acceptance::MAX_DECLARED_RELEASE_LIMITATIONS - ]; + + assert_eq!( + decide_release( + passing_results(), + &[limitation.clone(), limitation], + ), + Err(ReleaseDecisionError::DuplicateLimitationClaim) + ); + Ok(()) +} + +#[test] +fn release_report_bounds_declared_limitation_count_before_cloning() +-> Result<(), ReleaseDecisionError> { + let maximum = (0..MAX_DECLARED_RELEASE_LIMITATIONS) + .map(|index| { + DeclaredLimitation::new( + format!("unsupported_profile_{index}"), + "This profile is excluded from the declared support profile.", + ) + }) + .collect::, _>>()?; let report = decide_release(passing_results(), &maximum)?; assert_eq!( @@ -349,13 +371,17 @@ fn release_report_bounds_declared_limitation_count_before_cloning() ); assert_eq!( report.declared_limitations().len(), - originweave_core::release_acceptance::MAX_DECLARED_RELEASE_LIMITATIONS + MAX_DECLARED_RELEASE_LIMITATIONS ); - let too_many = vec![ - limitation; - originweave_core::release_acceptance::MAX_DECLARED_RELEASE_LIMITATIONS + 1 - ]; + let too_many = (0..=MAX_DECLARED_RELEASE_LIMITATIONS) + .map(|index| { + DeclaredLimitation::new( + format!("unsupported_profile_{index}"), + "This profile is excluded from the declared support profile.", + ) + }) + .collect::, _>>()?; assert_eq!( decide_release(passing_results(), &too_many), Err(ReleaseDecisionError::TooManyDeclaredLimitations) From 49e98fba6974219b3bb0336c822b12667f1e1c03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 17:17:54 -0700 Subject: [PATCH 368/570] test(core): apply canonical rustfmt to duplicate limitation regression --- crates/originweave-core/tests/release_acceptance.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index dbac815d1..dd4f5501f 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -343,10 +343,7 @@ fn duplicate_limitation_claim_fails_closed_even_when_consequence_matches() let limitation = declared_limitation()?; assert_eq!( - decide_release( - passing_results(), - &[limitation.clone(), limitation], - ), + decide_release(passing_results(), &[limitation.clone(), limitation],), Err(ReleaseDecisionError::DuplicateLimitationClaim) ); Ok(()) From b7c210bf1f0383d25450d6f66521c1e43dafd36f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 12:19:23 +0900 Subject: [PATCH 369/570] docs: refresh gap baseline onto 2026-08-24 live inventory - Record 158 open PRs (44 ready, 114 draft) with refreshed exact base/head evidence for the #208-#222 release, enterprise-approval, BAP, and WARC/PROV chains while retaining the 2026-08-21 rows as regression anchors. - Add governance issues #212 and #215 to the operational signal table and note issue #206 closure between snapshots. - Record the required-check provider-failure RCA for the fail-closed Strix re-dispatches on #208, #218, and #220 without weakening the gate. - Tighten the completion-gap contract so superseded inventory counts cannot pass as current evidence. --- CHANGELOG.md | 2 + docs/product-technical-gap-baseline.md | 50 +++++++++++++++---- tests/test_product_completion_gap_contract.py | 9 ++-- tests/test_product_documentation_contract.py | 2 +- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d30d1f5..9846f6968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- 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. - Added a dated product and technical gap baseline that separates protected-main implementation truth, active pull-request evidence, live review/check blockers, and the next buyer-visible Phase 1 acceptance work. - Refreshed the product and technical gap baseline with the current open-PR inventory and exact base/head evidence for the newest Chromium, BAP, extraction, WARC, and idempotency slices. - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. @@ -50,6 +51,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. - Tightened the product-baseline contract so the BiDi opening path and VPN/profile evidence retain their explicit not-shipped status within their own documentation sections. - Refreshed the product and technical gap baseline against the 2026-08-21 live inventory: 150 open pull requests, 110 drafts, and the new hardened-runner/MV3 evidence gap issue #206. +- Tightened the baseline completion-gap contract so superseded inventory counts (including the 2026-08-21 150/40/110 snapshot) can no longer pass as current evidence. - Refreshed the baseline's merge-authority statement to the live ruleset: two approving reviews are required, while the collaborator inventory still contains only the solo maintainer. - Corrected the baseline evidence collector to flatten every paginated input, apply current reviewer and last-push approval semantics, and discard verdicts when either the PR head or base moves. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e76daeaf8..e29438545 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2,11 +2,11 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, architecture decisions, or live GitHub state. It keeps buyer-visible gaps, current issues, active pull-request evidence, and commercial completion tracks in one discoverable place. Protected `main` is the implementation boundary: code in an open pull request is not shipped behavior. -## Observed snapshot: 2026-08-21 +## Observed snapshot: 2026-08-24 ### Protected-main truth -- Protected `main` was at `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` when this snapshot was refreshed. +- Protected `main` remained at `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` when this snapshot was refreshed. - Phase 0 is documented as complete as a reusable safety-kernel foundation: typed policy contracts, destination classification, direct TCP peer verification, TLS service identity, evidence bounds, resource mitigation, document-node authority, and protected-main tests. - Phase 1 is **in progress**, not shipped. The first real Chromium vertical slice still needs the active WebDriver BiDi transport stack to reach protected `main`, then compose isolated Chromium launch, session/context identity, semantic observation, typed action authorization, native browser input, post-condition proof, evidence, cancellation, crash recovery, and profile/process teardown. - HTTP/1.1 bounds, downloads/MIME, proxy/PAC consumption, full browser-network integration, the sensitive-data broker runtime, durable WARC/PROV capture, persistent task/API surfaces, signed cross-platform distribution, enterprise administration, and release-grade buyer acceptance remain open. @@ -14,17 +14,21 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, a ### Open pull requests -The live repository contained **150 open pull requests: 40 non-draft and 110 draft** after PRs #70 and #71 moved to Ready for review. The current snapshot also includes the newer #73 and #208–#211 product slices below. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **158 open pull requests: 44 non-draft and 114 draft** when this snapshot re-paginated the complete open inventory. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. Representative active workstreams at this snapshot were: | Workstream | Representative active PR evidence | Delivery boundary | |---|---|---| -| Product baseline | #196 | Ready/non-draft documentation PR; this refreshed inventory and the completion issues below remain review-gated | -| WebDriver BiDi transport | #188 through #205 | #205, exact head `c5746a61ede9e0214be9c1feeff7f4f1af790016`, exercises a bounded `locateNodes` exchange on top of the opening-path stack; the stack still does not by itself complete authenticated browser-process provenance, semantic task execution, or protected-main shipment | +| Product baseline | #196 | Ready/non-draft documentation PR; all exact-head checks passed and review threads resolved, blocked only by the reviewer-provisioning gap below | +| Enterprise approval authority | #220 | Ready/non-draft bounded maker-checker approval lifecycle on the exact `ApprovalScope`; Strix re-scan was re-dispatched after a provider-unavailability failure | +| Release artifact identity | #218 and #219 | Ready/non-draft fail-closed benchmark release decision and canonical release manifest binding; #218's Strix re-scan was re-dispatched after provider unavailability | +| Evidence path conformance | #216 | Ready/non-draft RFC 3986 evidence-path syntax enforcement | +| Schema-bound extraction and BAP lifecycle | #209 and #208 | Ready/non-draft schema-bound extraction contract and resumable task-lifecycle kernel; #208's Strix re-scan was re-dispatched after provider unavailability | +| WebDriver BiDi transport | #188 through #205 | Draft stack exercising framed `locateNodes` exchange over a bounded WebSocket opening path; still no authenticated browser-process provenance, semantic task execution, or protected-main shipment | | MCP adapter | #168 and #170 | Typed MCP routing and conservative `tools/list` metadata are active-PR foundations; complete authenticated transport, durable task lifecycle, cancellation/resume, and browser execution remain open under #200 | | Controlled Chromium and recovery | #65, #70-#73, #100, #105, #142-#153 and descendants | Real pinned-browser fixture, semantic location, resource, crash, and teardown evidence exists on active stacks; evidence does not transfer across heads or prerequisites | -| BAP and durable-evidence tracks | #208-#211 | Resumable lifecycle, schema-bound extraction, bounded WARC resources, and exact idempotent receipts are active-PR foundations; authenticated transport, durable ownership, replay, and browser side-effect reconciliation remain open | +| Durable WARC/PROV evidence | #210, #217 | Bounded WARC resource records and PROV JSON-LD binding are draft active-PR foundations; durable ownership, replay, retention/deletion, and browser side-effect reconciliation remain open | | Manifest V3 and native messaging | #27 and its active extension/native-host stack, including #154 and #169 | Compatibility and Agent-authority isolation remain incomplete until exact release artifacts and platform matrices are proven | | Sensitive-data and model route policy | #10 and its active policy stacks | Deterministic policy values exist, but trusted broker execution, retention/deletion, runtime isolation, and auditable product workflows remain open | | VPN/profile intent | #149 | Bounded WireGuard/IKEv2 profile authority is active-PR evidence; it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof | @@ -45,6 +49,31 @@ The following newest product slices were re-fetched from GitHub for this snapsho These rows are delivery evidence only. #73's latest Strix remediation is locally verified but its required policy workflows remain queued; #208–#211 are stacked product-gap foundations with no protected-main promotion. None has counted independent approval in the current collaborator inventory. +#### Refreshed exact-head active PR evidence: 2026-08-24 + +The following newest slices were re-fetched from GitHub for this snapshot. Heads have moved since the 2026-08-21 rows above; those predecessor rows are retained as regression anchors and must never be promoted to current-head evidence: + +| PR | State | Exact base head | Exact head | +|---|---|---|---| +| #222 | Draft | `56fcfa56525e4f2e980e0ee05b6776d621bcddc5` | `1e2ce3d4071a1a75ee891bdcd71c506b3b50d4bc` | +| #221 | Draft | `8145d40f1b028a8f4dc7e7da47ac89bb9e5bb2c7` | `6f339df1e5b3ddb265f4ddd7b262d4de1e0b5e1f` | +| #220 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `ed4cab16cf88c76ce1c145a22d0a274ef2d57263` | +| #219 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `8145d40f1b028a8f4dc7e7da47ac89bb9e5bb2c7` | +| #218 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `49e98fba6974219b3bb0336c822b12667f1e1c03` | +| #217 | Draft | `529d11a3571f6b1834b9baa49ef67eb08f043978` | `56fcfa56525e4f2e980e0ee05b6776d621bcddc5` | +| #216 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `75130851a0f7ce528a7a36382eb026ac7942a0aa` | +| #214 | Draft | `40d642d5470a7753b8211907c190367f742f2f12` | `f79999681866ecf0e5fe17d895170f3f6cae7361` | +| #211 | Draft | `85cc477688246900697f4cfb91c0c8f1f692934a` | `40d642d5470a7753b8211907c190367f742f2f12` | +| #210 | Draft | `c38b9665774d6b3754e572bed527737b5e179833` | `529d11a3571f6b1834b9baa49ef67eb08f043978` | +| #209 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `c38b9665774d6b3754e572bed527737b5e179833` | +| #208 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `85cc477688246900697f4cfb91c0c8f1f692934a` | + +The stack topology shows #209 → #210 → #217 → #222 (WARC/PROV chain), #208 → #211 → #214 (BAP chain), #218 → #221 → #220 (release/enterprise chain) at this snapshot. Every row above remains active-PR evidence; none is protected-main behavior. + +### Required-check provider failure record + +On 2026-08-23 the required Strix security scan failed closed on exact heads of #220 (`ed4cab16…`), #218 (`49e98fba…`), and #208 (`85cc4776…`) because its LLM provider/backend was unavailable (rate limit, token cap, connection, warm-up, or model-behavior failure); no vulnerability report artifact was produced, so the workflow correctly refused to convert an incomplete scan into passing security evidence. Failed jobs were re-dispatched on the unchanged exact heads on 2026-08-24. This is a provider-infrastructure failure record, not a weakening of the fail-closed gate or a substitute for a completed authoritative scan. + #### #195/#198 WebDriver BiDi opening path status Phase 1 is **in progress**, not shipped. #195 and #198 provide bounded WebSocket opening-path evidence on active branches; framed BiDi commands, authenticated browser-process provenance, semantic task execution, and protected-main integration remain open. @@ -71,12 +100,15 @@ This gap does not authorize self-approval, administrative bypass, stale-head mer | #10 | Purpose-bound operational PII disclosure and trusted broker/storage lifecycle | | #123 | Fleet incident: disable orphaned TLS, HTTP, and one-shot workflow identities | | #187 | Manual-authority review of the coverage-diagnostics workflow delta | +| #212 | Governance: remove or independently authorize the PR #43 MV3 workflow mutation | +| #215 | Governance: restore an enforceable protected-main policy that does not create a routine admin bypass | | #199 | Schema-bound extraction with durable WARC/PROV replay, retention, deletion, and offline verification | | #200 | Stable BAP/MCP runtime API with authenticated, idempotent, cancellable, resumable task lifecycle | | #201 | Signed cross-platform Chromium distribution, installer/updater, patch SLA, rollback, SBOM, and provenance | | #202 | Enterprise control and experience plane: operator UI, Keyverse-compatible identity, tenancy, approval, audit, SLO, Figma, and Storybook | | #203 | Release-grade web-agent benchmark and commercial acceptance gate bound to exact signed artifacts | -| #206 | Harden-runner custom detection initialization failure while the MV3 gate remains green | + +Issue #206 (harden-runner custom detection initialization failure) was closed after its remediation landed on protected `main` between snapshots. The five newly separated product-completion tracks are **durable WARC/PROV replay**, **stable BAP/MCP runtime API**, **signed cross-platform Chromium distribution**, **enterprise control and experience plane**, and the **commercial acceptance gate**. They are separate issues because each has a distinct authority, data, release, and buyer-acceptance boundary. @@ -95,7 +127,7 @@ The hourly product-development loop is operational infrastructure, not proof tha | P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | | P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | | P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | -| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 150-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 158-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | ## Commercial completion definition @@ -114,7 +146,7 @@ OriginWeave is not complete merely because every low-level primitive exists in s ## Next executable queue -1. Re-fetch all 150 PRs and compute the dependency graph, exact heads/bases, reviews, unresolved threads, current required checks, duplicate/supersession relationships, and branch ancestry. +1. Re-fetch all 158 open PRs and compute the dependency graph, exact heads/bases, reviews, unresolved threads, current required checks, duplicate/supersession relationships, and branch ancestry. Re-dispatch required checks that failed closed on provider infrastructure instead of code defects. 2. Integrate merge-ready root PRs first; restack and independently revalidate only the immediate children. Close obsolete alternatives instead of carrying parallel truth. 3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #195/#198 WebSocket opening path and the remaining framed BiDi command/response, semantic observation, policy, action, post-condition, and recovery boundaries. 4. Finish #27 and #10 as separate security tracks; neither should be hidden inside the first browser PR. diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 4b7bb1d38..e5f33067a 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -17,9 +17,9 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: text = BASELINE.read_text(encoding="utf-8") for phrase in ( - "150 open pull requests", - "40 non-draft", - "110 draft", + "158 open pull requests", + "44 non-draft", + "114 draft", "#198", "#199", "#200", @@ -41,6 +41,9 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: "78 draft", "148 open pull requests", "79 draft PRs", + "150 open pull requests", + "40 non-draft", + "110 draft", ): with self.subTest(stale_phrase=stale_phrase): self.assertNotIn(stale_phrase, text) diff --git a/tests/test_product_documentation_contract.py b/tests/test_product_documentation_contract.py index 211223c8f..5a1c1133c 100644 --- a/tests/test_product_documentation_contract.py +++ b/tests/test_product_documentation_contract.py @@ -44,7 +44,7 @@ def test_product_technical_gap_baseline_records_live_delivery_state(self) -> Non self.assertTrue(baseline.is_file()) text = baseline.read_text(encoding="utf-8") for phrase in ( - "Observed snapshot: 2026-08-21", + "Observed snapshot: 2026-08-24", "Protected-main truth", "Open pull requests", "Open issues", From 5c41742a9ad6c3be27d269e84eab1ead1272be13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 20:39:35 -0700 Subject: [PATCH 370/570] test(docs): fail closed on unknown last-push actor --- tests/test_product_completion_gap_contract.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index e5f33067a..839393f30 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -65,7 +65,6 @@ def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/statuses?per_page=100"', '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100"', '"repos/ContextualWisdomLab/OriginWeave/actions/runs?head_sha=$HEAD_SHA&per_page=100"', - '"$EVIDENCE_DIR/pr-${PR}-head-commit.json"', "check_runs: [$checks[][].check_runs[]?],", "legacy_statuses: [$statuses[][][]?]", "workflow_runs: [$workflow_runs[][].workflow_runs[]?],", @@ -81,7 +80,9 @@ def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> "group_by(.reviewer)", "required_approving_review_count", "require_last_push_approval", - "$head_commit[0].committer.login", + "last_push_approval_authority", + '"github_rule_evaluation_required"', + "if $pull_request_parameters.require_last_push_approval == true then false", "$pr[0].user.login", '.type == "workflows"', ".parameters.workflows", @@ -101,6 +102,9 @@ def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> self.assertNotIn("legacy_statuses: [$statuses[][]?]", shell) self.assertNotIn("workflow_runs: [$workflow_runs[]?.workflow_runs[]?],", shell) self.assertNotIn("$reviews[][]?\n | select(.state", shell) + self.assertNotIn("head-commit.json", shell) + self.assertNotIn("$head_commit[0].committer.login", shell) + self.assertNotIn("$head_commit[0].author.login", shell) if __name__ == "__main__": From c990ad60e14848bd7fb9f602c82afc198378f85f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 20:43:28 -0700 Subject: [PATCH 371/570] fix(docs): fail closed on last-push approval authority --- docs/product-technical-gap-baseline.md | 28 ++++++++++++++------------ 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e29438545..234e6ae5c 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -199,9 +199,6 @@ jq -r '.[].number' "$EVIDENCE_DIR/open-prs.json" | while read -r PR; do HEAD_SHA=$(jq -r '.head.sha' "$PR_JSON") BASE_SHA=$(jq -r '.base.sha' "$PR_JSON") - gh api "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA" \ - > "$EVIDENCE_DIR/pr-${PR}-head-commit.json" - gh api --paginate --slurp \ "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100" \ > "$EVIDENCE_DIR/pr-${PR}-check-runs.json" @@ -239,7 +236,6 @@ query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { --slurpfile workflow_runs "$EVIDENCE_DIR/pr-${PR}-workflow-runs.json" \ --slurpfile rules "$EVIDENCE_DIR/main-branch-rules.json" \ --slurpfile collaborators "$EVIDENCE_DIR/collaborators.json" \ - --slurpfile head_commit "$EVIDENCE_DIR/pr-${PR}-head-commit.json" \ --slurpfile threads "$EVIDENCE_DIR/pr-${PR}-review-threads.json" \ --arg base "$BASE_SHA" \ '( @@ -249,7 +245,6 @@ query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { | .parameters ] | first // {} ) as $pull_request_parameters - | ($head_commit[0].committer.login // $head_commit[0].author.login // "") as $last_push_actor | ( [ $reviews[][][]? @@ -265,13 +260,10 @@ query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { ] | group_by(.reviewer) | map(sort_by(.submitted_at) | last) - | map(select( - ($pull_request_parameters.require_last_push_approval != true) - or .reviewer != $last_push_actor - )) | map(select(.state == "APPROVED" and .commit_id == $head)) ) as $current_approvals | ($pull_request_parameters.required_approving_review_count // 0) as $required_review_count + | ($pull_request_parameters.require_last_push_approval // false) as $require_last_push_approval | { head_sha: $head, base_sha: $base, @@ -282,8 +274,18 @@ query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { workflow_runs: [$workflow_runs[][].workflow_runs[]?], counted_approvals: ($current_approvals | length), required_approving_review_count: $required_review_count, - require_last_push_approval: ($pull_request_parameters.require_last_push_approval // false), - approval_gate_satisfied: (($current_approvals | length) >= $required_review_count), + require_last_push_approval: $require_last_push_approval, + last_push_approval_authority: ( + if $require_last_push_approval == true + then "github_rule_evaluation_required" + else "not_required" + end + ), + approval_gate_satisfied: ( + if $pull_request_parameters.require_last_push_approval == true then false + else (($current_approvals | length) >= $required_review_count) + end + ), required_workflows: [ $rules[][]? | select(.type == "workflows") @@ -318,6 +320,6 @@ query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { done ``` -The branch-scoped rules response determines the active rules affecting `main`; each PR's exact `HEAD_SHA` then determines which check runs, legacy statuses, workflow runs, reviews, and unresolved threads are current. The saved merge verdict binds counted approvals to the latest review per eligible collaborator, excludes the PR author and (when required) the last-push actor, applies the required approval count and last-push rule, and requires `APPROVED` on the exact head. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. Evidence is retained only when both `RECHECKED_HEAD_SHA` and `RECHECKED_BASE_SHA` match the collected values; a moving head or base discards the temporary verdict, and three failed attempts leave no unstable merge verdict. +The branch-scoped rules response determines the active rules affecting `main`; each PR's exact `HEAD_SHA` then determines which check runs, legacy statuses, workflow runs, reviews, and unresolved threads are current. The saved merge verdict binds counted approvals to the latest review per eligible collaborator, excludes the PR author, and requires `APPROVED` on the exact head. It deliberately does **not** infer GitHub's actual last-push actor from commit author or committer metadata: when `require_last_push_approval` is active, this portable evidence procedure records `github_rule_evaluation_required` and keeps `approval_gate_satisfied` false until GitHub's authoritative rule evaluation is consulted. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. Evidence is retained only when both `RECHECKED_HEAD_SHA` and `RECHECKED_BASE_SHA` match the collected values; a moving head or base discards the temporary verdict, and three failed attempts leave no unstable merge verdict. -For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. +For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. \ No newline at end of file From 8b83eafe9579b4504044c0bed963430267c90408 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 01:15:41 -0700 Subject: [PATCH 372/570] test(mcp): bound tools list method metadata --- .../tests/mcp_tools_list_cache.rs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/mcp_tools_list_cache.rs b/crates/originweave-core/tests/mcp_tools_list_cache.rs index d271778f9..cf281675b 100644 --- a/crates/originweave-core/tests/mcp_tools_list_cache.rs +++ b/crates/originweave-core/tests/mcp_tools_list_cache.rs @@ -1,8 +1,8 @@ use std::error::Error; use originweave_core::mcp::{ - MCP_PROTOCOL_VERSION, MCP_TOOLS_LIST_METHOD, McpCacheScope, McpResultType, - McpToolsListBoundaryError, ValidatedMcpToolsListRequest, mcp_tools_list_page, + MAX_MCP_METHOD_NAME_BYTES, MCP_PROTOCOL_VERSION, MCP_TOOLS_LIST_METHOD, McpCacheScope, + McpResultType, McpToolsListBoundaryError, ValidatedMcpToolsListRequest, mcp_tools_list_page, supported_mcp_tools, }; @@ -94,6 +94,30 @@ fn mcp_tools_list_request_requires_complete_request_metadata() { ); } +#[test] +fn mcp_tools_list_validates_each_method_before_cross_field_comparison() { + let oversized_method = "a".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); + + for (routing_method, body_method) in [ + ("tools list", MCP_TOOLS_LIST_METHOD), + (MCP_TOOLS_LIST_METHOD, "tools list"), + (oversized_method.as_str(), MCP_TOOLS_LIST_METHOD), + (MCP_TOOLS_LIST_METHOD, oversized_method.as_str()), + ] { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + routing_method, + body_method, + None, + ), + Err(McpToolsListBoundaryError::InvalidMethod) + ); + } +} + #[test] fn mcp_tools_list_request_requires_exact_routing_and_no_unissued_cursor() { assert_eq!( From 0fade315c4d011a343cfb24bfbf1c5dc0b811717 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 01:19:59 -0700 Subject: [PATCH 373/570] fix(mcp): bound tools list method metadata --- crates/originweave-core/src/mcp.rs | 41 +++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index b27e8c15f..1b0c61437 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -20,6 +20,9 @@ pub const MCP_TOOLS_CALL_METHOD: &str = "tools/call"; /// The MCP discovery method accepted by the typed tools-list boundary. pub const MCP_TOOLS_LIST_METHOD: &str = "tools/list"; +/// Maximum accepted MCP method-name length in bytes. +pub const MAX_MCP_METHOD_NAME_BYTES: usize = 64; + /// Maximum accepted MCP tool-name length in bytes. pub const MAX_MCP_TOOL_NAME_BYTES: usize = 128; @@ -223,6 +226,8 @@ pub enum McpToolsListBoundaryError { UnsupportedProtocolVersion, /// The structured request metadata omitted the required client-capabilities object. MissingClientCapabilities, + /// The request method violates the bounded ASCII MCP routing syntax. + InvalidMethod, /// MCP routing method metadata disagrees with the method in the request body. MethodHeaderBodyMismatch, /// The request method is not the supported `tools/list` operation. @@ -249,6 +254,9 @@ impl fmt::Display for McpToolsListBoundaryError { Self::MissingClientCapabilities => { formatter.write_str("MCP request metadata client capabilities are required") } + Self::InvalidMethod => { + formatter.write_str("MCP method violates the bounded ASCII routing syntax") + } Self::MethodHeaderBodyMismatch => { formatter.write_str("MCP method header does not match the request body") } @@ -284,9 +292,10 @@ impl ValidatedMcpToolsListRequest { /// Both the required transport protocol-version header and structured request `_meta` /// protocol version must be present, equal, and exactly [`MCP_PROTOCOL_VERSION`]. A trusted /// structured parser must also attest that the required `_meta` client-capabilities object was - /// present; its contents grant no OriginWeave authority. The routing/body method must agree. - /// Any supplied cursor fails closed because [`mcp_tools_list_page`] emits no continuation - /// cursor; accepting one would silently invent pagination state that OriginWeave never issued. + /// present; its contents grant no OriginWeave authority. Each untrusted method value is + /// shape-validated before comparison. The routing/body method must then agree exactly. Any + /// supplied cursor fails closed because [`mcp_tools_list_page`] emits no continuation cursor; + /// accepting one would silently invent pagination state that OriginWeave never issued. pub fn new( protocol_version_header: Option<&str>, protocol_version_metadata: Option<&str>, @@ -309,6 +318,9 @@ impl ValidatedMcpToolsListRequest { if !client_capabilities_present { return Err(McpToolsListBoundaryError::MissingClientCapabilities); } + if !valid_method(routing_method) || !valid_method(body_method) { + return Err(McpToolsListBoundaryError::InvalidMethod); + } if routing_method != body_method { return Err(McpToolsListBoundaryError::MethodHeaderBodyMismatch); } @@ -338,6 +350,8 @@ pub enum McpToolBoundaryError { UnsupportedProtocolVersion, /// MCP routing metadata disagrees with the method or tool name in the body. HeaderBodyMismatch, + /// The request method violates the bounded ASCII MCP routing syntax. + InvalidMethod, /// The request method is not the supported `tools/call` operation. UnsupportedMethod, /// The tool name violates the bounded ASCII MCP routing syntax. @@ -355,6 +369,9 @@ impl fmt::Display for McpToolBoundaryError { Self::HeaderBodyMismatch => { formatter.write_str("MCP routing headers do not match the request body") } + Self::InvalidMethod => { + formatter.write_str("MCP method violates the bounded ASCII routing syntax") + } Self::UnsupportedMethod => formatter .write_str("only MCP tools/call requests can enter the typed action boundary"), Self::InvalidToolName => { @@ -382,9 +399,9 @@ impl ValidatedMcpToolCall { /// Routing integrity is intentionally narrower than authorization. A /// successful value proves only that the untrusted protocol version, /// routing metadata, body method, and body tool name agree with one - /// explicitly supported mapping. Each untrusted tool name is shape-validated - /// before cross-field comparison so malformed or oversized names cannot - /// bypass the bounded routing syntax through mismatch handling. + /// explicitly supported mapping. Each untrusted method and tool name is + /// shape-validated before cross-field comparison so malformed or oversized + /// metadata cannot bypass the bounded routing syntax through mismatch handling. pub fn new( protocol_version: &str, routing_method: &str, @@ -395,6 +412,9 @@ impl ValidatedMcpToolCall { if protocol_version != MCP_PROTOCOL_VERSION { return Err(McpToolBoundaryError::UnsupportedProtocolVersion); } + if !valid_method(routing_method) || !valid_method(body_method) { + return Err(McpToolBoundaryError::InvalidMethod); + } if !valid_tool_name(routing_tool_name) || !valid_tool_name(body_tool_name) { return Err(McpToolBoundaryError::InvalidToolName); } @@ -425,6 +445,15 @@ impl ValidatedMcpToolCall { } } +fn valid_method(method: &str) -> bool { + if method.is_empty() || method.len() > MAX_MCP_METHOD_NAME_BYTES { + return false; + } + method + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/')) +} + fn valid_tool_name(tool_name: &str) -> bool { if tool_name.is_empty() || tool_name.len() > MAX_MCP_TOOL_NAME_BYTES { return false; From 972d97b21bd79a8e28f6462513d4678760894b47 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 01:22:57 -0700 Subject: [PATCH 374/570] docs(changelog): reconcile merged MCP routing truth --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 116c02fe6..d7d3934e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ All notable changes to OriginWeave are documented in this file. The format follo - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. -- Active PR #168 adds deterministic MCP `2026-07-28` stateless tool-routing foundations with bounded names, a single reviewed tool-to-action registry shared by routing and discovery metadata, and fail-closed policy binding that grants no ambient authority. This is active-PR evidence only; the complete MCP adapter, transport serialization, discovery response handling, OAuth, browser I/O, and persistence remain planned until separately integrated on protected `main`. +- Protected main now contains deterministic MCP `2026-07-28` stateless `tools/call` routing with bounded method/tool names, a single reviewed tool-to-action registry shared by routing and discovery metadata, and fail-closed policy binding that grants no ambient authority. The complete MCP adapter, transport serialization, discovery response handling, OAuth, browser I/O, and persistence remain planned. +- Active PR #170 adds conservative MCP `2026-07-28` `tools/list` discovery metadata derived from that protected-main catalog, with `resultType = complete`, zero freshness, private cache scope, no continuation cursor, per-request protocol/client-capability admission, and bounded method metadata validated before cross-field comparison. This remains active-PR evidence only and grants no browser, network, secret, approval, or Agent authority. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. From 6242d6bd4632fb5926d56676dd4f39bb1cc636f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 01:28:31 -0700 Subject: [PATCH 375/570] test(mcp): cover tools list invalid method error --- crates/originweave-core/tests/mcp_tools_list_cache.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/originweave-core/tests/mcp_tools_list_cache.rs b/crates/originweave-core/tests/mcp_tools_list_cache.rs index cf281675b..17da1842b 100644 --- a/crates/originweave-core/tests/mcp_tools_list_cache.rs +++ b/crates/originweave-core/tests/mcp_tools_list_cache.rs @@ -174,6 +174,10 @@ fn mcp_tools_list_request_errors_are_source_free_and_non_echoing() { McpToolsListBoundaryError::MissingClientCapabilities, "MCP request metadata client capabilities are required", ), + ( + McpToolsListBoundaryError::InvalidMethod, + "MCP method violates the bounded ASCII routing syntax", + ), ( McpToolsListBoundaryError::MethodHeaderBodyMismatch, "MCP method header does not match the request body", From 9d1cfd08a7aa4203f697a40fd3e5fb30bbbd4d31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 01:46:36 -0700 Subject: [PATCH 376/570] test(mcp): bound tools-list protocol metadata --- .../tests/mcp_tools_list_cache.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/originweave-core/tests/mcp_tools_list_cache.rs b/crates/originweave-core/tests/mcp_tools_list_cache.rs index 17da1842b..9d3681673 100644 --- a/crates/originweave-core/tests/mcp_tools_list_cache.rs +++ b/crates/originweave-core/tests/mcp_tools_list_cache.rs @@ -94,6 +94,28 @@ fn mcp_tools_list_request_requires_complete_request_metadata() { ); } +#[test] +fn mcp_tools_list_bounds_protocol_metadata_before_cross_field_comparison() { + let oversized_protocol_version = format!("{MCP_PROTOCOL_VERSION}0"); + + for (header, metadata) in [ + (oversized_protocol_version.as_str(), MCP_PROTOCOL_VERSION), + (MCP_PROTOCOL_VERSION, oversized_protocol_version.as_str()), + ] { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(header), + Some(metadata), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::UnsupportedProtocolVersion) + ); + } +} + #[test] fn mcp_tools_list_validates_each_method_before_cross_field_comparison() { let oversized_method = "a".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); From c25fb51d01ee318cc49d2fe8223e6427d7f8e1b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 01:50:20 -0700 Subject: [PATCH 377/570] fix(mcp): bound protocol metadata before compare --- crates/originweave-core/src/mcp.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs index 1b0c61437..c7200e327 100644 --- a/crates/originweave-core/src/mcp.rs +++ b/crates/originweave-core/src/mcp.rs @@ -290,12 +290,13 @@ impl ValidatedMcpToolsListRequest { /// Validate the stateless request envelope for the current fixed `tools/list` catalog. /// /// Both the required transport protocol-version header and structured request `_meta` - /// protocol version must be present, equal, and exactly [`MCP_PROTOCOL_VERSION`]. A trusted - /// structured parser must also attest that the required `_meta` client-capabilities object was - /// present; its contents grant no OriginWeave authority. Each untrusted method value is - /// shape-validated before comparison. The routing/body method must then agree exactly. Any - /// supplied cursor fails closed because [`mcp_tools_list_page`] emits no continuation cursor; - /// accepting one would silently invent pagination state that OriginWeave never issued. + /// protocol version must be present, individually bounded to the exact supported-version + /// length before cross-field comparison, equal, and exactly [`MCP_PROTOCOL_VERSION`]. A + /// trusted structured parser must also attest that the required `_meta` client-capabilities + /// object was present; its contents grant no OriginWeave authority. Each untrusted method + /// value is shape-validated before comparison. The routing/body method must then agree exactly. + /// Any supplied cursor fails closed because [`mcp_tools_list_page`] emits no continuation + /// cursor; accepting one would silently invent pagination state that OriginWeave never issued. pub fn new( protocol_version_header: Option<&str>, protocol_version_metadata: Option<&str>, @@ -309,6 +310,11 @@ impl ValidatedMcpToolsListRequest { let protocol_version_metadata = protocol_version_metadata .ok_or(McpToolsListBoundaryError::MissingProtocolVersionMetadata)?; + if protocol_version_header.len() > MCP_PROTOCOL_VERSION.len() + || protocol_version_metadata.len() > MCP_PROTOCOL_VERSION.len() + { + return Err(McpToolsListBoundaryError::UnsupportedProtocolVersion); + } if protocol_version_header != protocol_version_metadata { return Err(McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch); } From bc41f375175b391c1625f3518a890e2bc0d882ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 01:53:34 -0700 Subject: [PATCH 378/570] docs(changelog): record bounded MCP protocol metadata --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d3934e4..dad4488e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. - Protected main now contains deterministic MCP `2026-07-28` stateless `tools/call` routing with bounded method/tool names, a single reviewed tool-to-action registry shared by routing and discovery metadata, and fail-closed policy binding that grants no ambient authority. The complete MCP adapter, transport serialization, discovery response handling, OAuth, browser I/O, and persistence remain planned. -- Active PR #170 adds conservative MCP `2026-07-28` `tools/list` discovery metadata derived from that protected-main catalog, with `resultType = complete`, zero freshness, private cache scope, no continuation cursor, per-request protocol/client-capability admission, and bounded method metadata validated before cross-field comparison. This remains active-PR evidence only and grants no browser, network, secret, approval, or Agent authority. +- Active PR #170 adds conservative MCP `2026-07-28` `tools/list` discovery metadata derived from that protected-main catalog, with `resultType = complete`, zero freshness, private cache scope, no continuation cursor, per-request protocol/client-capability admission, and bounded protocol-version and method metadata validated before cross-field comparison. This remains active-PR evidence only and grants no browser, network, secret, approval, or Agent authority. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. From fcae054d83a5f5ca7e0e4400249f994dab753769 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 02:01:03 -0700 Subject: [PATCH 379/570] docs(readme): align MCP shipped and active truth --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a956ff60b..0942976cf 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ OriginWeave is a Chromium-compatible, Rust-first control plane for governed AI agents on the web. It is designed to let an agent observe, extract, and act without turning untrusted page content into authority, exposing secrets to a model, connecting to an unapproved network destination, accepting an unauthenticated web service, or losing the evidence required to explain what happened. -> Project status: pre-alpha. The current protected repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, and authenticated TLS service-identity kernels. Chromium, WebDriver BiDi, CDP, complete MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. Active PR #168 implements only a bounded MCP `2026-07-28` stateless tool-routing and typed-action/policy foundation; it remains non-shipped active-PR evidence and does not make the complete MCP adapter available. +> Project status: pre-alpha. The current protected repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, authenticated TLS service-identity, and bounded MCP `2026-07-28` stateless `tools/call` routing/policy foundations. Chromium, WebDriver BiDi, CDP, complete MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. Active PR #170 implements only conservative `tools/list` discovery metadata on top of the protected-main MCP catalog; it remains non-shipped active-PR evidence and does not make the complete MCP adapter available. ## Why OriginWeave @@ -40,7 +40,7 @@ The repository is organized as independently consumable Rust crates: - `originweave-resource`: task-level RAM, VRAM, thread, and frame-time budgets with cumulative mitigation plans. - `originweave-evidence`: universally value-redacted network evidence and source-bound provenance records. -Active PR #168 additionally carries a non-shipped `originweave-core` MCP routing registry and `originweave-policy` binding for the MCP `2026-07-28` `tools/call` boundary. That foundation validates and maps an explicit tool name to an existing typed action; it does not implement transport parsing, `tools/list`, OAuth, browser control, secret materialization, persistence, or ambient authority. +Protected main additionally contains an `originweave-core` MCP routing registry and `originweave-policy` binding for the MCP `2026-07-28` `tools/call` boundary. That shipped foundation validates and maps an explicit tool name to an existing typed action while preserving normal OriginWeave policy. Active PR #170 adds non-shipped conservative `tools/list` discovery metadata derived from the same reviewed catalog. Neither boundary implements transport parsing, OAuth, browser control, secret materialization, persistence, or ambient authority. See [ARCHITECTURE.md](ARCHITECTURE.md) and the [architecture decision records](docs/adr/) for binding design decisions. @@ -99,7 +99,7 @@ isolated Chromium session → redacted provenance bundle ``` -Subsequent work connects the live Chromium network service, adds explicit proxy and download policy, WARC/PROV persistence, completes the MCP and Browser Agent Protocol adapters beyond the active routing foundation, expands extension compatibility testing, adds GPU/RAM telemetry and prompt-injection benchmarks, and builds an accessible approval interface. See [docs/product-roadmap.md](docs/product-roadmap.md). +Subsequent work connects the live Chromium network service, adds explicit proxy and download policy, WARC/PROV persistence, completes the MCP and Browser Agent Protocol adapters beyond the protected-main `tools/call` foundation and active `tools/list` refinement, expands extension compatibility testing, adds GPU/RAM telemetry and prompt-injection benchmarks, and builds an accessible approval interface. See [docs/product-roadmap.md](docs/product-roadmap.md). ## Hourly product-development loop @@ -111,4 +111,4 @@ Read [AGENTS.md](AGENTS.md), [CONTRIBUTING.md](CONTRIBUTING.md), and [SECURITY.m ## License -Apache License 2.0. See [LICENSE](LICENSE). +Apache License 2.0. See [LICENSE](LICENSE). \ No newline at end of file From 769ca7b2542b25bf48b87288f2661293df221219 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 02:03:13 -0700 Subject: [PATCH 380/570] docs(traceability): reconcile merged MCP routing --- docs/traceability/mcp-authority-route.md | 31 ++++++++++++++---------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/traceability/mcp-authority-route.md b/docs/traceability/mcp-authority-route.md index ddbd5927c..94f181ed4 100644 --- a/docs/traceability/mcp-authority-route.md +++ b/docs/traceability/mcp-authority-route.md @@ -1,35 +1,38 @@ # MCP 2026-07-28 authority-route traceability -- **Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` -- **Owning work:** PR #168 `feat(mcp): bind stateless tool routing to typed actions` -- **Protected-main status:** non-shipped active-PR evidence +- **`tools/call` capability maturity:** `IMPLEMENTED_ON_PROTECTED_MAIN` +- **`tools/list` capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` +- **Protected-main owning work:** merged PR #168 `feat(mcp): bind stateless tool routing to typed actions` +- **Active follow-on:** PR #170 `feat(mcp): expose conservative tools list cache contract` - **Complete MCP adapter status:** `PLANNED` - **Governing decision:** ADR 0107 ## Scope -PR #168 implements a bounded Rust control-plane foundation for MCP `2026-07-28` `tools/call` routing. It validates the represented stateless routing envelope, bounds and syntax-validates both attacker-controlled method fields and both attacker-controlled tool-name fields before correlation, maps only an explicit reviewed `originweave.*` catalog to existing typed `ActionKind` values, derives discovery metadata from the same catalog, and rejects route/action mismatch before ordinary deterministic policy evaluation. Methods are nonempty reviewed-ASCII routing tokens of at most 64 bytes; tool names are nonempty reviewed-ASCII identifiers of at most 128 bytes. Invalid method metadata is rejected distinctly from a bounded but unsupported MCP method. +Protected main at `b05d5acca82b9d916ada2c8e82f59f92a89817e1` contains the bounded Rust control-plane foundation for MCP `2026-07-28` `tools/call` routing that merged through PR #168. It validates the represented stateless routing envelope, bounds and syntax-validates both attacker-controlled method fields and both attacker-controlled tool-name fields before correlation, maps only an explicit reviewed `originweave.*` catalog to existing typed `ActionKind` values, derives discovery metadata from the same catalog, and rejects route/action mismatch before ordinary deterministic policy evaluation. Methods are nonempty reviewed-ASCII routing tokens of at most 64 bytes; tool names are nonempty reviewed-ASCII identifiers of at most 128 bytes. Invalid method metadata is rejected distinctly from a bounded but unsupported MCP method. A successful `ValidatedMcpToolCall` proves routing integrity only. It grants no capability, origin, approval, secret, browser, tenant, persistence, network, or evidence authority. `originweave_policy::evaluate_mcp` still delegates to the ordinary policy evaluator after the route/action match. +Active PR #170 builds on that protected-main catalog with a conservative typed `tools/list` request/result boundary. Its current branch requires matching MCP protocol metadata, required client-capability presence, bounded and syntax-validated routing/body methods, exact `tools/list` routing, and no caller-supplied cursor because the fixed catalog issues none. Its result is one complete page with zero freshness, private cache scope, and no continuation cursor. This active-PR slice remains non-shipped until it reaches protected main and does not grant any OriginWeave action authority. + ## Product-status reconciliation -`docs/PRD.md` PRD-INT-004 and `docs/TRD.md` Section 12 intentionally remain **Planned** at the complete-adapter level. That status is not contradicted by this active PR: the PR implements only a reusable routing/action-policy foundation below the product adapter. `README.md` and `CHANGELOG.md` therefore distinguish the active foundation from shipped protected-main capability, and ADR 0107 records the same version and authority boundary. +`docs/PRD.md` PRD-INT-004 and `docs/TRD.md` Section 12 intentionally remain **Planned** at the complete-adapter level. That status is not contradicted by the bounded `tools/call` foundation now on protected main or by active PR #170: both are reusable control-plane contracts below the complete product adapter. `README.md` and `CHANGELOG.md` distinguish protected-main routing from the active discovery refinement, and ADR 0107 records the protocol/version and authority boundary. -The following remain outside PR #168 and must not be inferred from it: +The following remain outside protected main and PR #170 and must not be inferred from either: - Streamable HTTP transport parsing and header materialization; -- complete request `_meta` validation, including per-request client capabilities; -- `tools/list` serialization, pagination, cache semantics, and subscription handling; +- JSON-RPC/HTTP response serialization of the typed discovery page; - OAuth and authenticated MCP deployment policy; - browser-control I/O or BiDi/CDP/WebMCP translation; - secret materialization or broker transport; -- persistence, durable audit storage, or WARC/PROV export; and +- persistence, durable audit storage, or WARC/PROV export; +- general pagination/subscription state beyond the fixed no-cursor catalog; and - an OriginWeave Protocol version transition. ## Version boundary -The active routing foundation accepts only protocol generation `2026-07-28`. MCP versioning is independent of the OriginWeave Protocol. A later MCP revision does not silently change OriginWeave action, risk, capability, approval, secret, origin, tenant, browser, or evidence semantics. +The protected-main routing foundation and active discovery refinement accept only protocol generation `2026-07-28`. MCP versioning is independent of the OriginWeave Protocol. A later MCP revision does not silently change OriginWeave action, risk, capability, approval, secret, origin, tenant, browser, or evidence semantics. The reviewed primary source is: @@ -39,15 +42,17 @@ The canonical bibliography remains `docs/doctoring.md`. ## Executable evidence -Current PR #168 production/test surfaces include: +Protected-main PR #168 production/test surfaces include: - `crates/originweave-core/src/mcp.rs` — bounded deterministic catalog plus method/tool routing validation in the `ValidatedMcpToolCall` primitive; - `crates/originweave-core/tests/mcp_authority_route.rs` — mapping, exact method/tool bounds, empty/oversized/malformed inputs, version/method/header-body correlation, and error-contract evidence; - `crates/originweave-policy/src/lib.rs` — `evaluate_mcp` route/action guard before normal policy evaluation; and - `crates/originweave-policy/tests/mcp_route_binding.rs` — confused-deputy and policy-preservation evidence. -Exact current-head CI/security/review evidence must be regenerated after every branch mutation. Predecessor-head success is historical only. +Active PR #170 additionally exercises its discovery contract in `crates/originweave-core/tests/mcp_tools_list_cache.rs`, including result/cache semantics, required protocol/client metadata, bounded protocol and method validation, routing correlation, cursor rejection, and public error contracts. + +Exact current-head CI/security/review evidence must be regenerated after every branch mutation. Protected-main evidence proves only the merged `tools/call` foundation; predecessor or protected-main results are not current-head proof for active PR #170. ## Promotion rule -This dossier may change to `IMPLEMENTED_ON_PROTECTED_MAIN` for the bounded routing foundation only after PR #168 reaches protected `main` under live governance and exact-head acceptance. That promotion still does **not** promote the complete MCP adapter from `PLANNED`; each remaining transport/runtime boundary requires its own integrated evidence. +The bounded `tools/call` routing foundation is already `IMPLEMENTED_ON_PROTECTED_MAIN`. The `tools/list` discovery refinement may change to `IMPLEMENTED_ON_PROTECTED_MAIN` only after PR #170 reaches protected `main` under live governance and exact-head acceptance. Neither promotion makes the complete MCP adapter implemented; each remaining transport/runtime boundary requires its own integrated evidence. From 5bb5a7bb934d4282c30b0ea4f4b2efc9a00a1f2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 02:05:33 -0700 Subject: [PATCH 381/570] docs(adr): reconcile protected-main MCP routing --- docs/adr/0107-browser-protocol-adapter-strategy.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/adr/0107-browser-protocol-adapter-strategy.md b/docs/adr/0107-browser-protocol-adapter-strategy.md index e3c0bf657..fb1bf2e17 100644 --- a/docs/adr/0107-browser-protocol-adapter-strategy.md +++ b/docs/adr/0107-browser-protocol-adapter-strategy.md @@ -36,11 +36,13 @@ MCP version negotiation is independent of the OriginWeave Protocol version. As o ### Current implementation boundary -The complete MCP adapter remains **Planned**. Active PR #168 is narrower **IMPLEMENTED_ON_ACTIVE_PR** evidence inside the Rust control plane: it validates the `2026-07-28` stateless `tools/call` routing envelope presented to this boundary, bounds and syntax-checks both untrusted method fields and both untrusted tool-name fields before cross-field correlation, derives one of the existing typed `ActionKind` values from a deterministic reviewed registry, exposes discovery metadata from that same registry, and requires the resulting action to pass the ordinary OriginWeave policy evaluator. The method boundary accepts only nonempty ASCII method names up to 64 bytes using the reviewed routing alphabet, while the tool-name boundary accepts only nonempty ASCII names up to 128 bytes using its narrower reviewed alphabet. The catalog and validated route grant no capability, approval, origin, secret, browser, persistence, or evidence authority by themselves. +The complete MCP adapter remains **Planned**. Protected main now contains the narrower bounded Rust `tools/call` routing/action-policy foundation merged through PR #168. That protected-main foundation validates the `2026-07-28` stateless `tools/call` routing envelope presented to this boundary, bounds and syntax-checks both untrusted method fields and both untrusted tool-name fields before cross-field correlation, derives one of the existing typed `ActionKind` values from a deterministic reviewed registry, exposes discovery metadata from that same registry, and requires the resulting action to pass the ordinary OriginWeave policy evaluator. The method boundary accepts only nonempty ASCII method names up to 64 bytes using the reviewed routing alphabet, while the tool-name boundary accepts only nonempty ASCII names up to 128 bytes using its narrower reviewed alphabet. The catalog and validated route grant no capability, approval, origin, secret, browser, persistence, or evidence authority by themselves. -PR #168 does not implement Streamable HTTP transport parsing, complete request `_meta` validation, `tools/list` serialization/caching/pagination, OAuth, browser I/O, WebMCP/BiDi/CDP translation, secret delivery, persistence, or a complete OriginWeave Protocol adapter. Those remain separate adapter/runtime work. Protected `main` therefore must continue to describe MCP as planned until this active-PR evidence is integrated, and even after integration only the merged bounded routing foundation may be called implemented; the full adapter remains planned until its remaining acceptance boundaries ship. +Active PR #170 is a separate non-shipped refinement on top of that protected-main catalog. It adds one conservative typed `tools/list` request/result contract: both protocol-version fields are required and bounded before comparison, client-capability metadata must be present without becoming authority, both routing/body methods are syntax-bounded before correlation, only exact `tools/list` is admitted, and every caller-supplied cursor is rejected because the current fixed catalog issues none. The result is one complete page with zero freshness, private cache scope, and no continuation cursor. -The version boundary is explicit: the routing foundation accepts only MCP `2026-07-28`; it does not infer compatibility with later protocol generations. OriginWeave Protocol versioning remains independent and cannot be changed by MCP metadata. +Neither protected main nor PR #170 implements Streamable HTTP transport parsing, JSON-RPC/HTTP serialization, OAuth, browser I/O, WebMCP/BiDi/CDP translation, secret delivery, persistence, general pagination/subscription state, or a complete OriginWeave Protocol adapter. Those remain separate adapter/runtime work. Protected `main` may therefore describe only the bounded merged `tools/call` foundation as implemented; the full MCP adapter remains planned, and the `tools/list` refinement remains active-PR evidence until separately integrated. + +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. ## Consequences @@ -58,7 +60,7 @@ Protocol validation occurs before messages influence policy. Tool/page-provided Require version-negotiation tests, schema/property tests, malformed-message tests, BiDi/CDP semantic parity tests for shared capabilities, WebMCP prompt-injection tests, MCP authority-separation and version-change tests, browser-version compatibility matrices, and end-to-end proof that unsupported capabilities fail without side effects. -For active PR #168 specifically, acceptance additionally requires deterministic method and tool-name bounds/syntax, exact header/body method and tool-name correlation only after both sides are bounded, explicit invalid-method/invalid-tool-name/unknown-tool rejection, one unambiguous tool-to-action registry, independent capability/risk expectations, route/action mismatch denial before ordinary policy evaluation, exact 100% owned-production coverage, and unchanged-head CI/security/review evidence. These checks do not substitute for complete transport or adapter conformance. +For the protected-main `tools/call` foundation, acceptance includes deterministic method and tool-name bounds/syntax, exact header/body method and tool-name correlation only after both sides are bounded, explicit invalid-method/invalid-tool-name/unknown-tool rejection, one unambiguous tool-to-action registry, independent capability/risk expectations, route/action mismatch denial before ordinary policy evaluation, exact 100% owned-production coverage, and integrated review evidence from PR #168. For active PR #170, exact-current acceptance additionally requires bounded protocol metadata before cross-field comparison, required client-capabilities presence, bounded `tools/list` method correlation, rejection of unissued cursors, deterministic result/cache semantics, exact 100% owned-production coverage, and unchanged-head CI/security/review evidence. These checks do not substitute for complete transport or adapter conformance. ## Migration and rollback @@ -66,7 +68,7 @@ Adapters are independently versioned and can be canaried. Clients migrate throug ## Open follow-ups -Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, complete MCP Streamable HTTP/request-metadata validation, MCP discovery/serialization/cache behavior, and MCP/WebMCP schema isolation. +Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, complete MCP Streamable HTTP/request-metadata validation, MCP transport serialization, authenticated deployment, and MCP/WebMCP schema isolation. ## Supersession / reversal conditions From c1ddbbaa15965804a4eb01be4c8232f4670ae4ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 03:08:04 -0700 Subject: [PATCH 382/570] docs(doctoring): record browser origin port syntax --- docs/doctoring.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/doctoring.md b/docs/doctoring.md index 693840f63..d8085cb07 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -16,6 +16,10 @@ The WHATWG URL host parser and Chromium canonicalizer classify shortened decimal The exact Chromium regression evidence is pinned to revision `446d05d21720f0b3505ec21057b3e9f909784262`. A mutable `HEAD` reference is not sufficient for a reproducible security contract. +### Browser origin port syntax + +The WHATWG URL Standard port state accepts ASCII decimal digits while accumulating an explicit port and treats any other code point in that state as a validation error. Rust 1.97.1 `u16::from_str`, by contrast, accepts an optional leading `+` before decimal digits. OriginWeave therefore does not delegate browser-origin port grammar directly to integer parsing: every explicit origin port token must be non-empty and contain only ASCII decimal digits before `u16` range parsing. This prevents values such as `https://example.com:+443` from being canonicalized into browser authority even though Rust accepts the numeric spelling. The guard changes no default-port normalization, host parsing, destination authority, DNS, socket, or TLS semantics. + ### Extension-to-Agent grant origin binding RFC 6454 defines a web origin as the scheme, host, and port tuple that browsers use to isolate authority. An OriginWeave `extension_grant` that is bound only to extension identity, session, and browsing context would remain valid after the same context navigates to another origin. OriginWeave therefore requires the grant and the request to carry the same canonical origin. A host change or a non-default port change is a different origin and cannot reuse the grant. This is grant-scope isolation only; it does not install an extension, parse Chrome messages, or mint Agent capabilities from Manifest V3 permissions. @@ -174,6 +178,8 @@ The Rust Project Developers. (2026). *Ipv6Addr in std::net* (Rust 1.97.1) [Softw The Rust Project Developers. (2026). *TcpStream in std::net* (Rust 1.97.1) [Software documentation]. https://doc.rust-lang.org/stable/std/net/struct.TcpStream.html +The Rust Project Developers. (2026). *u16 in std* (Rust 1.97.1) [Software documentation]. https://doc.rust-lang.org/stable/std/primitive.u16.html + Web Hypertext Application Technology Working Group. (2026). *URL standard*. https://url.spec.whatwg.org/ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ From bc9aae1215f2d07005ded1eeff5da0f7f1663920 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 03:17:57 -0700 Subject: [PATCH 383/570] docs(doctoring): make URL port-state evidence exact --- docs/doctoring.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/doctoring.md b/docs/doctoring.md index d8085cb07..1a1b989aa 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -18,7 +18,7 @@ The exact Chromium regression evidence is pinned to revision `446d05d21720f0b350 ### Browser origin port syntax -The WHATWG URL Standard port state accepts ASCII decimal digits while accumulating an explicit port and treats any other code point in that state as a validation error. Rust 1.97.1 `u16::from_str`, by contrast, accepts an optional leading `+` before decimal digits. OriginWeave therefore does not delegate browser-origin port grammar directly to integer parsing: every explicit origin port token must be non-empty and contain only ASCII decimal digits before `u16` range parsing. This prevents values such as `https://example.com:+443` from being canonicalized into browser authority even though Rust accepts the numeric spelling. The guard changes no default-port normalization, host parsing, destination authority, DNS, socket, or TLS semantics. +In the WHATWG URL Standard port state, ASCII digits are accumulated into the port buffer. EOF, `/`, `?`, `#`, and, for special URLs, `\` terminate the port state; any other code point is a `port-invalid` validation error. When the buffered port is committed, values outside the unsigned 16-bit range fail as `port-out-of-range`. Rust 1.97.1 `u16::from_str`, by contrast, accepts an optional leading `+` before decimal digits. OriginWeave therefore does not delegate browser-origin port-token grammar directly to integer parsing: every explicit origin port token must be non-empty and contain only ASCII decimal digits before `u16` range parsing. This rejects values such as `https://example.com:+443` without misclassifying URL delimiters as port digits; parsing of path, query, fragment, and other origin syntax remains a separate boundary. The guard changes no default-port normalization, host parsing, destination authority, DNS, socket, or TLS semantics. ### Extension-to-Agent grant origin binding @@ -122,7 +122,7 @@ Chromium Authors. (n.d.). *Proxy support in Chrome* [Source documentation]. Chro Chromium Authors. (2026). *URL canonicalizer unit tests* [Source code]. Chromium. https://chromium.googlesource.com/chromium/src/+/446d05d21720f0b3505ec21057b3e9f909784262/url/url_canon_unittest.cc -Cooper, D., Santesson, S., Farrell, S., Boeyen, S., Housley, R., & Polk, W. (2008). *Internet X.509 public key infrastructure certificate and certificate revocation list (CRL) profile* (RFC 5280). Internet Engineering Task Force. https://doi.org/10.17487/RFC5280 +Cooper, D., Santesson, S., Farrell, S., Boeyen, R., Housley, R., & Polk, W. (2008). *Internet X.509 public key infrastructure certificate and certificate revocation list (CRL) profile* (RFC 5280). Internet Engineering Task Force. https://doi.org/10.17487/RFC5280 Cotton, M., Vegoda, L., Bonica, R., & Haberman, B. (2013). *Special-purpose IP address registries* (RFC 6890). Internet Engineering Task Force. https://doi.org/10.17487/RFC6890 @@ -178,9 +178,9 @@ The Rust Project Developers. (2026). *Ipv6Addr in std::net* (Rust 1.97.1) [Softw The Rust Project Developers. (2026). *TcpStream in std::net* (Rust 1.97.1) [Software documentation]. https://doc.rust-lang.org/stable/std/net/struct.TcpStream.html -The Rust Project Developers. (2026). *u16 in std* (Rust 1.97.1) [Software documentation]. https://doc.rust-lang.org/stable/std/primitive.u16.html +The Rust Project Developers. (2026). *u16 in std* (Rust 1.97.1) [Software documentation]. https://doc.rust-lang.org/1.97.1/std/primitive.u16.html -Web Hypertext Application Technology Working Group. (2026). *URL standard*. https://url.spec.whatwg.org/ +Web Hypertext Application Technology Working Group. (2026). *URL standard*. Retrieved August 24, 2026, from https://url.spec.whatwg.org/ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ From 8ed3c6e9de344afdefe6e93a2ffe26dc77548aee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 03:18:43 -0700 Subject: [PATCH 384/570] docs(changelog): pin URL standard retrieval date --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0097d79b9..cb304b7a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,6 @@ All notable changes to OriginWeave are documented in this file. The format follo ### References -Web Hypertext Application Technology Working Group. (2026). *URL standard*. https://url.spec.whatwg.org/ +Web Hypertext Application Technology Working Group. (2026). *URL standard*. Retrieved August 24, 2026, from https://url.spec.whatwg.org/ -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file From 1f4793604990c5251c76c6972db69d0181e9e928 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 05:26:12 -0700 Subject: [PATCH 385/570] feat(core): restore release acceptance contract on current module layout --- .../src/release_acceptance.rs | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 crates/originweave-core/src/release_acceptance.rs diff --git a/crates/originweave-core/src/release_acceptance.rs b/crates/originweave-core/src/release_acceptance.rs new file mode 100644 index 000000000..b0d23a228 --- /dev/null +++ b/crates/originweave-core/src/release_acceptance.rs @@ -0,0 +1,344 @@ +//! Deterministic fail-closed release acceptance for commercial benchmark evidence. +//! +//! This module aggregates only explicit mandatory-suite outcomes and bounded, +//! buyer-visible limitations. It does not execute benchmarks, infer missing +//! evidence, authenticate artifacts, or grant release authority. + +use std::fmt; + +/// Maximum UTF-8 byte length retained for either buyer-visible limitation field. +pub const MAX_RELEASE_LIMITATION_TEXT_BYTES: usize = 1024; + +/// Maximum number of buyer-visible limitations retained in one release report. +pub const MAX_DECLARED_RELEASE_LIMITATIONS: usize = 64; + +/// One mandatory benchmark suite in the release acceptance contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BenchmarkSuite { + /// Controlled local fixtures with deterministic post-condition oracles. + ControlledDeterministic, + /// Stable web compatibility tasks for the declared support profile. + WebCompatibility, + /// Hostile security cases that measure unauthorized authority or disclosure. + SecurityAdversarial, + /// Crash, timeout, retry, reconciliation, cleanup, and restore behavior. + ReliabilityRecovery, + /// Enterprise isolation, identity, policy, audit, and operator controls. + EnterpriseOperability, +} + +impl BenchmarkSuite { + /// Every mandatory benchmark suite in canonical release-report order. + pub const ALL: [Self; 5] = [ + Self::ControlledDeterministic, + Self::WebCompatibility, + Self::SecurityAdversarial, + Self::ReliabilityRecovery, + Self::EnterpriseOperability, + ]; + + /// Return the stable snake-case suite identifier used by benchmark evidence. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ControlledDeterministic => "controlled_deterministic_suite", + Self::WebCompatibility => "web_compatibility_suite", + Self::SecurityAdversarial => "security_adversarial_suite", + Self::ReliabilityRecovery => "reliability_recovery_suite", + Self::EnterpriseOperability => "enterprise_operability_suite", + } + } + + const fn index(self) -> usize { + match self { + Self::ControlledDeterministic => 0, + Self::WebCompatibility => 1, + Self::SecurityAdversarial => 2, + Self::ReliabilityRecovery => 3, + Self::EnterpriseOperability => 4, + } + } +} + +/// Evaluated outcome for one mandatory benchmark suite. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BenchmarkSuiteOutcome { + /// Every threshold required for the declared profile passed. + Passed, + /// At least one mandatory threshold is known to have failed. + Failed, + /// Evidence is insufficient to establish either pass or threshold failure. + Inconclusive, +} + +/// One explicit narrowed release claim and its buyer-visible consequence. +/// +/// An accepted-with-limitations decision cannot be produced from an opaque +/// boolean. Every limitation must name the unsupported claim and state the +/// consequence that a buyer must account for in the declared support profile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeclaredLimitation { + unsupported_claim: String, + buyer_consequence: String, +} + +impl DeclaredLimitation { + /// Construct one explicit buyer-visible release limitation. + /// + /// Empty/whitespace-only values, fields exceeding the fixed UTF-8 byte budget, + /// and ambiguous presentation characters fail closed because they cannot safely + /// represent one unambiguous, resource-bounded buyer-visible release limitation. + pub fn new( + unsupported_claim: impl Into, + buyer_consequence: impl Into, + ) -> Result { + let unsupported_claim = unsupported_claim.into(); + if unsupported_claim.trim().is_empty() { + return Err(ReleaseDecisionError::EmptyLimitationClaim); + } + if unsupported_claim.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { + return Err(ReleaseDecisionError::LimitationClaimTooLong); + } + if unsupported_claim + .chars() + .any(disallowed_release_limitation_character) + { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } + let buyer_consequence = buyer_consequence.into(); + if buyer_consequence.trim().is_empty() { + return Err(ReleaseDecisionError::EmptyLimitationConsequence); + } + if buyer_consequence.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { + return Err(ReleaseDecisionError::LimitationConsequenceTooLong); + } + if buyer_consequence + .chars() + .any(disallowed_release_limitation_character) + { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } + Ok(Self { + unsupported_claim, + buyer_consequence, + }) + } + + /// Return the exact unsupported or narrowed release claim. + #[must_use] + pub fn unsupported_claim(&self) -> &str { + &self.unsupported_claim + } + + /// Return the exact consequence exposed to buyers and operators. + #[must_use] + pub fn buyer_consequence(&self) -> &str { + &self.buyer_consequence + } +} + +fn disallowed_release_limitation_character(character: char) -> bool { + let code_point = character as u32; + character.is_control() + || matches!( + code_point, + 0x00ad + | 0x034f + | 0x061c + | 0x115f..=0x1160 + | 0x17b4..=0x17b5 + | 0x180b..=0x180f + | 0x200b..=0x200f + | 0x2028..=0x202e + | 0x2060..=0x206f + | 0x3164 + | 0xfe00..=0xfe0f + | 0xfeff + | 0xffa0 + | 0xfff0..=0xfff8 + | 0x1bca0..=0x1bca3 + | 0x1d173..=0x1d17a + | 0xe0000..=0xe0fff + ) +} + +/// Deterministic release decision produced from mandatory suite evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseDecision { + /// Every mandatory suite passed for the full declared support profile. + Accepted, + /// Every mandatory suite passed after buyer-visible limitations were declared. + AcceptedWithDeclaredLimitations, + /// At least one mandatory suite is known to have failed its threshold. + Rejected, + /// No known threshold failure exists, but mandatory evidence is incomplete. + Inconclusive, +} + +/// Fail-closed input error while constructing a release decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseDecisionError { + /// A declared limitation did not identify the unsupported release claim. + EmptyLimitationClaim, + /// A declared limitation claim exceeded the fixed UTF-8 byte budget. + LimitationClaimTooLong, + /// A declared limitation claim contained an unsafe presentation character. + InvalidLimitationClaim, + /// A declared limitation did not state the buyer-visible consequence. + EmptyLimitationConsequence, + /// A declared limitation consequence exceeded the fixed UTF-8 byte budget. + LimitationConsequenceTooLong, + /// A declared limitation consequence contained an unsafe presentation character. + InvalidLimitationConsequence, + /// One release report supplied more buyer-visible limitations than the fixed resource budget. + TooManyDeclaredLimitations, + /// More than one limitation used the same unsupported claim identity. + DuplicateLimitationClaim, + /// The same suite appeared more than once instead of one authoritative result. + DuplicateSuite(BenchmarkSuite), +} + +impl fmt::Display for ReleaseDecisionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyLimitationClaim => formatter + .write_str("declared release limitation must name an unsupported claim"), + Self::LimitationClaimTooLong => formatter + .write_str("declared release limitation claim exceeds the byte budget"), + Self::InvalidLimitationClaim => formatter.write_str( + "declared release limitation claim contains an unsafe presentation character", + ), + Self::EmptyLimitationConsequence => formatter.write_str( + "declared release limitation must state a buyer-visible consequence", + ), + Self::LimitationConsequenceTooLong => formatter.write_str( + "declared release limitation consequence exceeds the byte budget", + ), + Self::InvalidLimitationConsequence => formatter.write_str( + "declared release limitation consequence contains an unsafe presentation character", + ), + Self::TooManyDeclaredLimitations => formatter + .write_str("benchmark release decision contains too many declared limitations"), + Self::DuplicateLimitationClaim => formatter + .write_str("benchmark release decision contains duplicate limitation claim"), + Self::DuplicateSuite(suite) => write!( + formatter, + "benchmark release evidence contains duplicate suite: {}", + suite.as_str() + ), + } + } +} + +impl std::error::Error for ReleaseDecisionError {} + +/// Release decision together with exact mandatory-suite evidence gaps and failures. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseDecisionReport { + decision: ReleaseDecision, + failed_suites: Vec, + inconclusive_suites: Vec, + missing_suites: Vec, + declared_limitations: Vec, +} + +impl ReleaseDecisionReport { + /// Return the deterministic release decision. + #[must_use] + pub const fn decision(&self) -> ReleaseDecision { + self.decision + } + + /// Return suites with a known mandatory-threshold failure. + #[must_use] + pub fn failed_suites(&self) -> &[BenchmarkSuite] { + &self.failed_suites + } + + /// Return suites whose supplied evidence was explicitly inconclusive. + #[must_use] + pub fn inconclusive_suites(&self) -> &[BenchmarkSuite] { + &self.inconclusive_suites + } + + /// Return mandatory suites for which no outcome was supplied. + #[must_use] + pub fn missing_suites(&self) -> &[BenchmarkSuite] { + &self.missing_suites + } + + /// Return the exact buyer-visible limitations retained with this decision. + #[must_use] + pub fn declared_limitations(&self) -> &[DeclaredLimitation] { + &self.declared_limitations + } +} + +/// Produce one deterministic release decision from mandatory suite outcomes. +/// +/// Duplicate suite evidence, duplicate buyer-visible limitation claim identities, +/// and excessive declared-limitation cardinality fail closed rather than selecting +/// or retaining ambiguous or attacker-controlled release metadata. A known +/// mandatory-threshold failure is always rejected, even when other suites are +/// missing or inconclusive; all such evidence gaps remain in the returned report. +/// Without a known failure, missing or inconclusive evidence is never promoted to +/// acceptance. Accepted-with-limitations requires at least one validated +/// [`DeclaredLimitation`], so the decision cannot be detached from the exact +/// narrowed claim and buyer-visible consequence. +pub fn decide_release( + results: I, + declared_limitations: &[DeclaredLimitation], +) -> Result +where + I: IntoIterator, +{ + if declared_limitations.len() > MAX_DECLARED_RELEASE_LIMITATIONS { + return Err(ReleaseDecisionError::TooManyDeclaredLimitations); + } + + let mut limitation_claims = std::collections::BTreeSet::new(); + for limitation in declared_limitations { + if !limitation_claims.insert(limitation.unsupported_claim()) { + return Err(ReleaseDecisionError::DuplicateLimitationClaim); + } + } + + let mut outcomes = [None; BenchmarkSuite::ALL.len()]; + for (suite, outcome) in results { + let slot = &mut outcomes[suite.index()]; + if slot.is_some() { + return Err(ReleaseDecisionError::DuplicateSuite(suite)); + } + *slot = Some(outcome); + } + + let mut failed_suites = Vec::new(); + let mut inconclusive_suites = Vec::new(); + let mut missing_suites = Vec::new(); + for suite in BenchmarkSuite::ALL { + match outcomes[suite.index()] { + Some(BenchmarkSuiteOutcome::Passed) => {} + Some(BenchmarkSuiteOutcome::Failed) => failed_suites.push(suite), + Some(BenchmarkSuiteOutcome::Inconclusive) => inconclusive_suites.push(suite), + None => missing_suites.push(suite), + } + } + + let decision = if !failed_suites.is_empty() { + ReleaseDecision::Rejected + } else if !inconclusive_suites.is_empty() || !missing_suites.is_empty() { + ReleaseDecision::Inconclusive + } else if declared_limitations.is_empty() { + ReleaseDecision::Accepted + } else { + ReleaseDecision::AcceptedWithDeclaredLimitations + }; + + Ok(ReleaseDecisionReport { + decision, + failed_suites, + inconclusive_suites, + missing_suites, + declared_limitations: declared_limitations.to_vec(), + }) +} From b1cab20748141c3114f08c2473001d27458c47d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 05:28:17 -0700 Subject: [PATCH 386/570] feat(core): export release acceptance contract --- crates/originweave-core/src/root.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs index 7acced460..c47a136d4 100644 --- a/crates/originweave-core/src/root.rs +++ b/crates/originweave-core/src/root.rs @@ -13,3 +13,5 @@ pub use contracts::*; /// Stateless MCP routing validation that maps only explicit tools to typed actions. pub mod mcp; +/// Deterministic fail-closed release benchmark acceptance aggregation. +pub mod release_acceptance; From 3974251e03fb4ec79f1fb213985974ae816dfe1d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 05:33:34 -0700 Subject: [PATCH 387/570] fix(core): require ASCII digits in explicit ports --- crates/originweave-core/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index b6ed55ff2..e33a7e7e5 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -165,6 +165,9 @@ fn parse_bracketed_ipv6(authority: &str) -> Result<(String, Option, bool), } fn parse_port(port_text: &str) -> Result { + if port_text.is_empty() || !port_text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(OriginError::InvalidPort); + } let port = port_text .parse::() .map_err(|_error| OriginError::InvalidPort)?; From 4626c71559437ff10738d98e3a98bdb7ae7e1bf2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 05:35:14 -0700 Subject: [PATCH 388/570] style(core): apply canonical rustfmt to release acceptance --- .../src/release_acceptance.rs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/originweave-core/src/release_acceptance.rs b/crates/originweave-core/src/release_acceptance.rs index b0d23a228..3d3f532e2 100644 --- a/crates/originweave-core/src/release_acceptance.rs +++ b/crates/originweave-core/src/release_acceptance.rs @@ -201,19 +201,19 @@ pub enum ReleaseDecisionError { impl fmt::Display for ReleaseDecisionError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::EmptyLimitationClaim => formatter - .write_str("declared release limitation must name an unsupported claim"), - Self::LimitationClaimTooLong => formatter - .write_str("declared release limitation claim exceeds the byte budget"), + Self::EmptyLimitationClaim => { + formatter.write_str("declared release limitation must name an unsupported claim") + } + Self::LimitationClaimTooLong => { + formatter.write_str("declared release limitation claim exceeds the byte budget") + } Self::InvalidLimitationClaim => formatter.write_str( "declared release limitation claim contains an unsafe presentation character", ), - Self::EmptyLimitationConsequence => formatter.write_str( - "declared release limitation must state a buyer-visible consequence", - ), - Self::LimitationConsequenceTooLong => formatter.write_str( - "declared release limitation consequence exceeds the byte budget", - ), + Self::EmptyLimitationConsequence => formatter + .write_str("declared release limitation must state a buyer-visible consequence"), + Self::LimitationConsequenceTooLong => formatter + .write_str("declared release limitation consequence exceeds the byte budget"), Self::InvalidLimitationConsequence => formatter.write_str( "declared release limitation consequence contains an unsafe presentation character", ), From 28cd9deb2563985497005e9fa29bc08b974b23a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 05:57:27 -0700 Subject: [PATCH 389/570] test(core): reject ambiguous release limitation whitespace --- .../release_acceptance_canonical_text.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 crates/originweave-core/tests/release_acceptance_canonical_text.rs diff --git a/crates/originweave-core/tests/release_acceptance_canonical_text.rs b/crates/originweave-core/tests/release_acceptance_canonical_text.rs new file mode 100644 index 000000000..a60af4c18 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_canonical_text.rs @@ -0,0 +1,30 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +#[test] +fn limitation_rejects_surrounding_whitespace_that_changes_claim_identity() { + for unsupported_claim in [" linux_arm64", "linux_arm64 ", "\tlinux_arm64"] { + assert_eq!( + DeclaredLimitation::new( + unsupported_claim, + "Linux ARM64 is excluded from the support profile.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "surrounding whitespace must not create a second spelling for one claim identity: {unsupported_claim:?}", + ); + } +} + +#[test] +fn limitation_rejects_surrounding_whitespace_in_buyer_consequence() { + for buyer_consequence in [ + " Linux ARM64 is excluded from the support profile.", + "Linux ARM64 is excluded from the support profile. ", + "Linux ARM64 is excluded from the support profile.\t", + ] { + assert_eq!( + DeclaredLimitation::new("linux_arm64", buyer_consequence), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "buyer-visible consequence must have one canonical boundary spelling: {buyer_consequence:?}", + ); + } +} From 2b7d24091aea43782e54bb103ac3a17d5b591173 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:00:55 -0700 Subject: [PATCH 390/570] fix(core): canonicalize release limitation boundaries --- crates/originweave-core/src/release_acceptance.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/originweave-core/src/release_acceptance.rs b/crates/originweave-core/src/release_acceptance.rs index 3d3f532e2..bb4e12870 100644 --- a/crates/originweave-core/src/release_acceptance.rs +++ b/crates/originweave-core/src/release_acceptance.rs @@ -85,9 +85,10 @@ pub struct DeclaredLimitation { impl DeclaredLimitation { /// Construct one explicit buyer-visible release limitation. /// - /// Empty/whitespace-only values, fields exceeding the fixed UTF-8 byte budget, - /// and ambiguous presentation characters fail closed because they cannot safely - /// represent one unambiguous, resource-bounded buyer-visible release limitation. + /// Empty/whitespace-only values, surrounding whitespace, fields exceeding the + /// fixed UTF-8 byte budget, and ambiguous presentation characters fail closed + /// because they cannot safely represent one canonical, resource-bounded + /// buyer-visible release limitation. pub fn new( unsupported_claim: impl Into, buyer_consequence: impl Into, @@ -96,6 +97,9 @@ impl DeclaredLimitation { if unsupported_claim.trim().is_empty() { return Err(ReleaseDecisionError::EmptyLimitationClaim); } + if unsupported_claim.trim() != unsupported_claim { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } if unsupported_claim.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { return Err(ReleaseDecisionError::LimitationClaimTooLong); } @@ -109,6 +113,9 @@ impl DeclaredLimitation { if buyer_consequence.trim().is_empty() { return Err(ReleaseDecisionError::EmptyLimitationConsequence); } + if buyer_consequence.trim() != buyer_consequence { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } if buyer_consequence.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { return Err(ReleaseDecisionError::LimitationConsequenceTooLong); } From ef7265399e5b72a7869374098bc9d908f36e611d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:06:17 -0700 Subject: [PATCH 391/570] docs(changelog): record release acceptance contract --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 116c02fe6..d85e084de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- 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. - Added a dated product and technical gap baseline that separates protected-main implementation truth, active pull-request evidence, live review/check blockers, and the next buyer-visible Phase 1 acceptance work. - Refreshed the product and technical gap baseline with the current open-PR inventory and exact base/head evidence for the newest Chromium, BAP, extraction, WARC, and idempotency slices. @@ -89,4 +90,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file From 562ab1c44b71fcf004bbcf15ad4989a3daf65847 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:08:11 -0700 Subject: [PATCH 392/570] docs(doctoring): pin Unicode 17 limitation basis --- docs/doctoring.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/doctoring.md b/docs/doctoring.md index 693840f63..375b82fa9 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -24,6 +24,10 @@ RFC 6454 defines a web origin as the scheme, host, and port tuple that browsers RFC 9700 is the current Best Current Practice for OAuth 2.0 security. It requires access tokens to be restricted in lifetime and treats long-lived bearer credentials as a standing authorization risk. An OriginWeave `extension_grant` that matches extension identity, session, browsing context, and canonical origin but has no exclusive expiry remains usable after the Agent Task window ends. OriginWeave therefore requires the grant to carry an exclusive `expires_at_epoch_seconds` deadline and the request to carry trusted `now_epoch_seconds`. Evaluation fails closed when `now >= expires_at`, matching the existing sensitive-handle exclusive-expiry rule. Page, extension, and model clocks are not trusted time. This slice does not bind task identity, install an extension, or mint Agent capabilities from Manifest V3 permissions. +### Release-limitation presentation safety + +Unicode 17.0 defines `Default_Ignorable_Code_Point` in the Unicode Character Database and records the exact derived set in the versioned `DerivedCoreProperties.txt` data file. Those characters can be invisible or alter presentation without supplying an ordinary visible glyph. OriginWeave therefore treats the Unicode 17.0 derived property as a pinned presentation-safety input for buyer-visible release-limitation metadata, in addition to rejecting control characters and non-canonical leading or trailing whitespace. The admitted text is not silently normalized: accepted content retains its exact bytes, while ambiguous presentation characters and surrounding whitespace fail closed so one release claim cannot acquire multiple stored spellings. This is a bounded metadata-identity policy, not a claim of complete Unicode spoofing resistance or semantic text equivalence. + ### Resolved destination and redirect safety Canonical origin identity is not a network-destination authorization. The IANA IPv4 and IPv6 Special-Purpose Address Space registries enumerate blocks whose source, destination, forwardability, globally reachable, and protocol-reserved properties differ. Both registries were last updated on 9 October 2025 and explicitly warn that registry presence does not guarantee routability in a particular local or global context. RFC 6890 established the common special-purpose registry fields, and RFC 8190 replaced the ambiguous `global` field with `globally reachable`. @@ -174,6 +178,8 @@ The Rust Project Developers. (2026). *Ipv6Addr in std::net* (Rust 1.97.1) [Softw The Rust Project Developers. (2026). *TcpStream in std::net* (Rust 1.97.1) [Software documentation]. https://doc.rust-lang.org/stable/std/net/struct.TcpStream.html +The Unicode Consortium. (2025). *DerivedCoreProperties-17.0.0.txt* [Data file]. https://www.unicode.org/Public/17.0.0/ucd/DerivedCoreProperties.txt + Web Hypertext Application Technology Working Group. (2026). *URL standard*. https://url.spec.whatwg.org/ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ From e5491c761fea89a9769862b9462de51e28c180fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:37:35 -0700 Subject: [PATCH 393/570] test(evidence): require handle lifecycle access binding --- .../tests/sensitive_handle_access_binding.rs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 crates/originweave-evidence/tests/sensitive_handle_access_binding.rs diff --git a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs new file mode 100644 index 000000000..d897edd59 --- /dev/null +++ b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs @@ -0,0 +1,91 @@ +use std::error::Error; + +use originweave_core::Origin; +use originweave_evidence::{ + SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, + SensitiveAccessOutcome, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, + SensitiveHandleLifecycleEvidenceInput, +}; + +fn access_evidence( + outcome: SensitiveAccessOutcome, + decision_epoch_seconds: u64, +) -> Result> { + Ok(SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { + request_id: "request-42".to_owned(), + decision_id: "decision-42".to_owned(), + tenant_id: "tenant-7".to_owned(), + actor_id: "workload-browser-adapter".to_owned(), + task_id: "task-99".to_owned(), + field_ids: vec!["shipping_name".to_owned(), "shipping_address".to_owned()], + purpose_id: "fulfill-shipment".to_owned(), + destination: Origin::parse("https://checkout.example.com")?, + classification: SensitiveAccessClass::PersonalData, + outcome, + policy_version: "sensitive-policy-v3".to_owned(), + approval_reference: None, + decision_epoch_seconds, + disclosure_epoch_seconds: None, + retention_deadline_epoch_seconds: Some(decision_epoch_seconds + 3_600), + })?) +} + +fn lifecycle_input( + access_evidence: SensitiveAccessEvidence, + issued_epoch_seconds: u64, +) -> SensitiveHandleLifecycleEvidenceInput { + SensitiveHandleLifecycleEvidenceInput { + access_evidence, + issued_epoch_seconds, + expires_epoch_seconds: issued_epoch_seconds + 300, + maximum_uses: 2, + resolution_count: 0, + revoked_epoch_seconds: None, + } +} + +#[test] +fn lifecycle_identity_retains_complete_opaque_handle_access_receipt() -> Result<(), Box> { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; + let evidence = SensitiveHandleLifecycleEvidence::try_from(lifecycle_input( + access.clone(), + 1_720_000_001, + ))?; + + assert_eq!(evidence.access_evidence(), &access); + assert_eq!(evidence.request_id(), access.request_id()); + assert_eq!(evidence.decision_id(), access.decision_id()); + assert_eq!(evidence.access_evidence().tenant_id(), "tenant-7"); + assert_eq!(evidence.access_evidence().task_id(), "task-99"); + assert_eq!( + evidence.access_evidence().field_ids(), + &["shipping_name".to_owned(), "shipping_address".to_owned()] + ); + assert_eq!( + evidence.access_evidence().destination().as_str(), + "https://checkout.example.com" + ); + Ok(()) +} + +#[test] +fn lifecycle_rejects_non_opaque_handle_access_decision() -> Result<(), Box> { + let denied = access_evidence(SensitiveAccessOutcome::DenyAccess, 1_720_000_000)?; + + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(denied, 1_720_000_001)), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} + +#[test] +fn lifecycle_rejects_issuance_before_policy_decision() -> Result<(), Box> { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_100)?; + + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access, 1_720_000_099)), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} From 8f6dd06cc5c585c82bd80e5db63c96b92f078ae2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:39:01 -0700 Subject: [PATCH 394/570] style(evidence): format handle access binding regression --- .../tests/sensitive_handle_access_binding.rs | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs index d897edd59..899300e09 100644 --- a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs +++ b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs @@ -11,23 +11,25 @@ fn access_evidence( outcome: SensitiveAccessOutcome, decision_epoch_seconds: u64, ) -> Result> { - Ok(SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { - request_id: "request-42".to_owned(), - decision_id: "decision-42".to_owned(), - tenant_id: "tenant-7".to_owned(), - actor_id: "workload-browser-adapter".to_owned(), - task_id: "task-99".to_owned(), - field_ids: vec!["shipping_name".to_owned(), "shipping_address".to_owned()], - purpose_id: "fulfill-shipment".to_owned(), - destination: Origin::parse("https://checkout.example.com")?, - classification: SensitiveAccessClass::PersonalData, - outcome, - policy_version: "sensitive-policy-v3".to_owned(), - approval_reference: None, - decision_epoch_seconds, - disclosure_epoch_seconds: None, - retention_deadline_epoch_seconds: Some(decision_epoch_seconds + 3_600), - })?) + Ok(SensitiveAccessEvidence::try_from( + SensitiveAccessEvidenceInput { + request_id: "request-42".to_owned(), + decision_id: "decision-42".to_owned(), + tenant_id: "tenant-7".to_owned(), + actor_id: "workload-browser-adapter".to_owned(), + task_id: "task-99".to_owned(), + field_ids: vec!["shipping_name".to_owned(), "shipping_address".to_owned()], + purpose_id: "fulfill-shipment".to_owned(), + destination: Origin::parse("https://checkout.example.com")?, + classification: SensitiveAccessClass::PersonalData, + outcome, + policy_version: "sensitive-policy-v3".to_owned(), + approval_reference: None, + decision_epoch_seconds, + disclosure_epoch_seconds: None, + retention_deadline_epoch_seconds: Some(decision_epoch_seconds + 3_600), + }, + )?) } fn lifecycle_input( @@ -45,12 +47,11 @@ fn lifecycle_input( } #[test] -fn lifecycle_identity_retains_complete_opaque_handle_access_receipt() -> Result<(), Box> { +fn lifecycle_identity_retains_complete_opaque_handle_access_receipt() -> Result<(), Box> +{ let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; - let evidence = SensitiveHandleLifecycleEvidence::try_from(lifecycle_input( - access.clone(), - 1_720_000_001, - ))?; + let evidence = + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access.clone(), 1_720_000_001))?; assert_eq!(evidence.access_evidence(), &access); assert_eq!(evidence.request_id(), access.request_id()); From 8926a5d09357f0198c5bac67ef0cf9ce50cbe46c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:40:46 -0700 Subject: [PATCH 395/570] fix(evidence): bind handle lifecycle to access authority --- .../src/sensitive_handle_lifecycle.rs | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs index de6e49b1e..c67880bff 100644 --- a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs +++ b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs @@ -2,21 +2,23 @@ //! //! A trusted broker can use this value object to record when a handle was //! issued, when it expires, how many uses it permits, how many resolutions were -//! observed, and when it was revoked. The evidence intentionally has no field -//! for the opaque handle token or the protected value behind that token. +//! observed, and when it was revoked. The lifecycle retains the complete +//! credential-free sensitive-access receipt that authorized opaque-handle use, +//! while intentionally excluding the opaque handle token and protected value. -use crate::sensitive_access::{SensitiveEvidenceError, valid_identifier}; +use crate::sensitive_access::{ + SensitiveAccessEvidence, SensitiveAccessOutcome, SensitiveEvidenceError, +}; /// Unvalidated metadata describing one opaque sensitive-value handle lifecycle. /// -/// This input records correlation identifiers and bounded lifecycle counters -/// only. It cannot carry the opaque handle token or a protected value. +/// The embedded access receipt binds the lifecycle to the tenant, actor, task, +/// field set, purpose, destination, classification, policy version, and exact +/// opaque-handle authorization without carrying protected values. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SensitiveHandleLifecycleEvidenceInput { - /// Correlation identifier for the sensitive-data access request. - pub request_id: String, - /// Identifier for the policy decision that authorized or denied the handle. - pub decision_id: String, + /// Credential-free access receipt that authorized this opaque handle. + pub access_evidence: SensitiveAccessEvidence, /// Trusted Unix epoch second when the handle was issued. pub issued_epoch_seconds: u64, /// Trusted Unix epoch second after which the handle is no longer valid. @@ -31,12 +33,12 @@ pub struct SensitiveHandleLifecycleEvidenceInput { /// Immutable credential-free evidence about one opaque handle lifecycle. /// -/// The value deliberately excludes both the opaque handle token and the secret -/// or protected value that the broker can resolve from it. +/// The value retains the exact credential-free sensitive-access receipt that +/// authorized opaque-handle use, but deliberately excludes both the opaque +/// handle token and the secret or protected value that the broker can resolve. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SensitiveHandleLifecycleEvidence { - request_id: String, - decision_id: String, + access_evidence: SensitiveAccessEvidence, issued_epoch_seconds: u64, expires_epoch_seconds: u64, maximum_uses: u32, @@ -48,10 +50,9 @@ impl TryFrom for SensitiveHandleLifecycle type Error = SensitiveEvidenceError; fn try_from(input: SensitiveHandleLifecycleEvidenceInput) -> Result { - if !valid_identifier(&input.request_id) || !valid_identifier(&input.decision_id) { - return Err(SensitiveEvidenceError::InvalidIdentifier); - } - if input.issued_epoch_seconds == 0 + if input.access_evidence.outcome() != SensitiveAccessOutcome::OpaqueHandleOnly + || input.issued_epoch_seconds == 0 + || input.issued_epoch_seconds < input.access_evidence.decision_epoch_seconds() || input.expires_epoch_seconds <= input.issued_epoch_seconds || input.maximum_uses == 0 || input.resolution_count > input.maximum_uses @@ -63,8 +64,7 @@ impl TryFrom for SensitiveHandleLifecycle } Ok(Self { - request_id: input.request_id, - decision_id: input.decision_id, + access_evidence: input.access_evidence, issued_epoch_seconds: input.issued_epoch_seconds, expires_epoch_seconds: input.expires_epoch_seconds, maximum_uses: input.maximum_uses, @@ -75,16 +75,22 @@ impl TryFrom for SensitiveHandleLifecycle } impl SensitiveHandleLifecycleEvidence { + /// Return the credential-free access receipt that authorized this opaque handle. + #[must_use] + pub const fn access_evidence(&self) -> &SensitiveAccessEvidence { + &self.access_evidence + } + /// Return the originating sensitive-data access request identifier. #[must_use] pub fn request_id(&self) -> &str { - &self.request_id + self.access_evidence.request_id() } /// Return the policy decision identifier associated with the handle. #[must_use] pub fn decision_id(&self) -> &str { - &self.decision_id + self.access_evidence.decision_id() } /// Return the trusted handle issuance time as a Unix epoch second. From 2756617d5c9dec1dddca12eb8b80a39b720e989d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:41:15 -0700 Subject: [PATCH 396/570] test(evidence): exercise exact handle authority binding --- .../tests/sensitive_handle_access_binding.rs | 61 ++++++++++--------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs index 899300e09..74a66356b 100644 --- a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs +++ b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs @@ -1,5 +1,3 @@ -use std::error::Error; - use originweave_core::Origin; use originweave_evidence::{ SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, @@ -7,29 +5,32 @@ use originweave_evidence::{ SensitiveHandleLifecycleEvidenceInput, }; +type TestResult = Result<(), String>; + fn access_evidence( outcome: SensitiveAccessOutcome, decision_epoch_seconds: u64, -) -> Result> { - Ok(SensitiveAccessEvidence::try_from( - SensitiveAccessEvidenceInput { - request_id: "request-42".to_owned(), - decision_id: "decision-42".to_owned(), - tenant_id: "tenant-7".to_owned(), - actor_id: "workload-browser-adapter".to_owned(), - task_id: "task-99".to_owned(), - field_ids: vec!["shipping_name".to_owned(), "shipping_address".to_owned()], - purpose_id: "fulfill-shipment".to_owned(), - destination: Origin::parse("https://checkout.example.com")?, - classification: SensitiveAccessClass::PersonalData, - outcome, - policy_version: "sensitive-policy-v3".to_owned(), - approval_reference: None, - decision_epoch_seconds, - disclosure_epoch_seconds: None, - retention_deadline_epoch_seconds: Some(decision_epoch_seconds + 3_600), - }, - )?) +) -> Result { + let destination = Origin::parse("https://checkout.example.com") + .map_err(|error| format!("{error:?}"))?; + SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { + request_id: "request-42".to_owned(), + decision_id: "decision-42".to_owned(), + tenant_id: "tenant-7".to_owned(), + actor_id: "workload-browser-adapter".to_owned(), + task_id: "task-99".to_owned(), + field_ids: vec!["shipping_name".to_owned(), "shipping_address".to_owned()], + purpose_id: "fulfill-shipment".to_owned(), + destination, + classification: SensitiveAccessClass::PersonalData, + outcome, + policy_version: "sensitive-policy-v3".to_owned(), + approval_reference: None, + decision_epoch_seconds, + disclosure_epoch_seconds: None, + retention_deadline_epoch_seconds: Some(decision_epoch_seconds + 3_600), + }) + .map_err(|error| format!("{error:?}")) } fn lifecycle_input( @@ -47,11 +48,13 @@ fn lifecycle_input( } #[test] -fn lifecycle_identity_retains_complete_opaque_handle_access_receipt() -> Result<(), Box> -{ +fn lifecycle_identity_retains_complete_opaque_handle_access_receipt() -> TestResult { let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; - let evidence = - SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access.clone(), 1_720_000_001))?; + let evidence = SensitiveHandleLifecycleEvidence::try_from(lifecycle_input( + access.clone(), + 1_720_000_001, + )) + .map_err(|error| format!("{error:?}"))?; assert_eq!(evidence.access_evidence(), &access); assert_eq!(evidence.request_id(), access.request_id()); @@ -60,7 +63,7 @@ fn lifecycle_identity_retains_complete_opaque_handle_access_receipt() -> Result< assert_eq!(evidence.access_evidence().task_id(), "task-99"); assert_eq!( evidence.access_evidence().field_ids(), - &["shipping_name".to_owned(), "shipping_address".to_owned()] + ["shipping_name", "shipping_address"] ); assert_eq!( evidence.access_evidence().destination().as_str(), @@ -70,7 +73,7 @@ fn lifecycle_identity_retains_complete_opaque_handle_access_receipt() -> Result< } #[test] -fn lifecycle_rejects_non_opaque_handle_access_decision() -> Result<(), Box> { +fn lifecycle_rejects_non_opaque_handle_access_decision() -> TestResult { let denied = access_evidence(SensitiveAccessOutcome::DenyAccess, 1_720_000_000)?; assert_eq!( @@ -81,7 +84,7 @@ fn lifecycle_rejects_non_opaque_handle_access_decision() -> Result<(), Box Result<(), Box> { +fn lifecycle_rejects_issuance_before_policy_decision() -> TestResult { let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_100)?; assert_eq!( From 4f5c248e8a2a8e5e9a5bb9f61c3b5fadf4fb9980 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:41:54 -0700 Subject: [PATCH 397/570] test(evidence): align lifecycle tests with access receipt --- .../sensitive_handle_lifecycle_evidence.rs | 106 +++++++++--------- 1 file changed, 55 insertions(+), 51 deletions(-) diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs index defc6f8de..4e4d9afc2 100644 --- a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs +++ b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs @@ -1,29 +1,55 @@ +use originweave_core::Origin; use originweave_evidence::{ - MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, + SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, + SensitiveAccessOutcome, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, SensitiveHandleLifecycleEvidenceInput, }; -fn valid_input() -> SensitiveHandleLifecycleEvidenceInput { - SensitiveHandleLifecycleEvidenceInput { +type TestResult = Result<(), String>; + +fn valid_access_evidence() -> Result { + let destination = + Origin::parse("https://shipping.example").map_err(|error| format!("{error:?}"))?; + SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { request_id: "request-42".to_owned(), decision_id: "decision-42".to_owned(), - issued_epoch_seconds: 1_720_000_000, - expires_epoch_seconds: 1_720_000_300, + tenant_id: "tenant-7".to_owned(), + actor_id: "workload-fulfillment".to_owned(), + task_id: "task-42".to_owned(), + field_ids: vec!["shipping.address".to_owned()], + purpose_id: "fulfill-shipment".to_owned(), + destination, + classification: SensitiveAccessClass::PersonalData, + outcome: SensitiveAccessOutcome::OpaqueHandleOnly, + policy_version: "sensitive-policy-v3".to_owned(), + approval_reference: None, + decision_epoch_seconds: 1_720_000_000, + disclosure_epoch_seconds: None, + retention_deadline_epoch_seconds: Some(1_720_003_600), + }) + .map_err(|error| format!("{error:?}")) +} + +fn valid_input() -> Result { + Ok(SensitiveHandleLifecycleEvidenceInput { + access_evidence: valid_access_evidence()?, + issued_epoch_seconds: 1_720_000_001, + expires_epoch_seconds: 1_720_000_301, maximum_uses: 2, resolution_count: 1, revoked_epoch_seconds: None, - } + }) } #[test] -fn records_bounded_handle_lifecycle_without_handle_or_secret_material() --> Result<(), SensitiveEvidenceError> { - let evidence = SensitiveHandleLifecycleEvidence::try_from(valid_input())?; +fn records_bounded_handle_lifecycle_without_handle_or_secret_material() -> TestResult { + let evidence = SensitiveHandleLifecycleEvidence::try_from(valid_input()?) + .map_err(|error| format!("{error:?}"))?; assert_eq!(evidence.request_id(), "request-42"); assert_eq!(evidence.decision_id(), "decision-42"); - assert_eq!(evidence.issued_epoch_seconds(), 1_720_000_000); - assert_eq!(evidence.expires_epoch_seconds(), 1_720_000_300); + assert_eq!(evidence.issued_epoch_seconds(), 1_720_000_001); + assert_eq!(evidence.expires_epoch_seconds(), 1_720_000_301); assert_eq!(evidence.maximum_uses(), 2); assert_eq!(evidence.resolution_count(), 1); assert_eq!(evidence.revoked_epoch_seconds(), None); @@ -36,13 +62,13 @@ fn records_bounded_handle_lifecycle_without_handle_or_secret_material() } #[test] -fn records_revocation_time_without_storing_revocation_payloads() --> Result<(), SensitiveEvidenceError> { - let mut input = valid_input(); +fn records_revocation_time_without_storing_revocation_payloads() -> TestResult { + let mut input = valid_input()?; input.revoked_epoch_seconds = Some(1_720_000_120); input.resolution_count = 2; - let evidence = SensitiveHandleLifecycleEvidence::try_from(input)?; + let evidence = SensitiveHandleLifecycleEvidence::try_from(input) + .map_err(|error| format!("{error:?}"))?; assert_eq!(evidence.revoked_epoch_seconds(), Some(1_720_000_120)); assert!(evidence.is_revoked()); @@ -51,38 +77,13 @@ fn records_revocation_time_without_storing_revocation_payloads() } #[test] -fn rejects_invalid_request_or_decision_identifiers() { - let invalid_request_ids = [ - String::new(), - "-".repeat(3), - "bad/request".to_owned(), - "a".repeat(MAX_SENSITIVE_IDENTIFIER_BYTES + 1), - ]; - for request_id in invalid_request_ids { - let mut input = valid_input(); - input.request_id = request_id; - assert_eq!( - SensitiveHandleLifecycleEvidence::try_from(input), - Err(SensitiveEvidenceError::InvalidIdentifier) - ); - } - - let mut invalid_decision = valid_input(); - invalid_decision.decision_id = String::new(); - assert_eq!( - SensitiveHandleLifecycleEvidence::try_from(invalid_decision), - Err(SensitiveEvidenceError::InvalidIdentifier) - ); -} - -#[test] -fn rejects_zero_or_non_increasing_handle_lifetime() { +fn rejects_zero_or_non_increasing_handle_lifetime() -> TestResult { for (issued, expires) in [ - (0, 1_720_000_300), - (1_720_000_300, 1_720_000_300), - (1_720_000_301, 1_720_000_300), + (0, 1_720_000_301), + (1_720_000_301, 1_720_000_301), + (1_720_000_302, 1_720_000_301), ] { - let mut input = valid_input(); + let mut input = valid_input()?; input.issued_epoch_seconds = issued; input.expires_epoch_seconds = expires; assert_eq!( @@ -90,33 +91,36 @@ fn rejects_zero_or_non_increasing_handle_lifetime() { Err(SensitiveEvidenceError::InvalidLifecycle) ); } + Ok(()) } #[test] -fn rejects_zero_use_limit_or_resolution_count_above_limit() { - let mut zero_limit = valid_input(); +fn rejects_zero_use_limit_or_resolution_count_above_limit() -> TestResult { + let mut zero_limit = valid_input()?; zero_limit.maximum_uses = 0; assert_eq!( SensitiveHandleLifecycleEvidence::try_from(zero_limit), Err(SensitiveEvidenceError::InvalidLifecycle) ); - let mut overused = valid_input(); + let mut overused = valid_input()?; overused.resolution_count = overused.maximum_uses + 1; assert_eq!( SensitiveHandleLifecycleEvidence::try_from(overused), Err(SensitiveEvidenceError::InvalidLifecycle) ); + Ok(()) } #[test] -fn rejects_revocation_outside_handle_lifetime() { - for revoked in [1_719_999_999, 1_720_000_300, 1_720_000_301] { - let mut input = valid_input(); +fn rejects_revocation_outside_handle_lifetime() -> TestResult { + for revoked in [1_720_000_000, 1_720_000_301, 1_720_000_302] { + let mut input = valid_input()?; input.revoked_epoch_seconds = Some(revoked); assert_eq!( SensitiveHandleLifecycleEvidence::try_from(input), Err(SensitiveEvidenceError::InvalidLifecycle) ); } + Ok(()) } From 7f7dffbd4830557b5d74b57da3d25b93c641d4ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:44:35 -0700 Subject: [PATCH 398/570] style(evidence): apply canonical rustfmt --- .../tests/sensitive_handle_access_binding.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs index 74a66356b..e773a70e5 100644 --- a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs +++ b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs @@ -11,8 +11,8 @@ fn access_evidence( outcome: SensitiveAccessOutcome, decision_epoch_seconds: u64, ) -> Result { - let destination = Origin::parse("https://checkout.example.com") - .map_err(|error| format!("{error:?}"))?; + let destination = + Origin::parse("https://checkout.example.com").map_err(|error| format!("{error:?}"))?; SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { request_id: "request-42".to_owned(), decision_id: "decision-42".to_owned(), @@ -50,11 +50,9 @@ fn lifecycle_input( #[test] fn lifecycle_identity_retains_complete_opaque_handle_access_receipt() -> TestResult { let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; - let evidence = SensitiveHandleLifecycleEvidence::try_from(lifecycle_input( - access.clone(), - 1_720_000_001, - )) - .map_err(|error| format!("{error:?}"))?; + let evidence = + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access.clone(), 1_720_000_001)) + .map_err(|error| format!("{error:?}"))?; assert_eq!(evidence.access_evidence(), &access); assert_eq!(evidence.request_id(), access.request_id()); From 7e92b48b36d473236997c254c3f5e269108cc152 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:45:07 -0700 Subject: [PATCH 399/570] style(evidence): finish canonical rustfmt --- .../tests/sensitive_handle_lifecycle_evidence.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs index 4e4d9afc2..f234abfa6 100644 --- a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs +++ b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs @@ -67,8 +67,8 @@ fn records_revocation_time_without_storing_revocation_payloads() -> TestResult { input.revoked_epoch_seconds = Some(1_720_000_120); input.resolution_count = 2; - let evidence = SensitiveHandleLifecycleEvidence::try_from(input) - .map_err(|error| format!("{error:?}"))?; + let evidence = + SensitiveHandleLifecycleEvidence::try_from(input).map_err(|error| format!("{error:?}"))?; assert_eq!(evidence.revoked_epoch_seconds(), Some(1_720_000_120)); assert!(evidence.is_revoked()); From 71a6fd9ea08fe1f628f8d9d6d6aaf4b4d2fec985 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:46:43 -0700 Subject: [PATCH 400/570] docs(destination): record bounded resolution freshness authority --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 116c02fe6..8935a05d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Active PR #168 adds deterministic MCP `2026-07-28` stateless tool-routing foundations with bounded names, a single reviewed tool-to-action registry shared by routing and discovery metadata, and fail-closed policy binding that grants no ambient authority. This is active-PR evidence only; the complete MCP adapter, transport serialization, discovery response handling, OAuth, browser I/O, and persistence remain planned until separately integrated on protected `main`. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. +- Bounded resolution-freshness authority with trusted monotonic approval time, capped non-zero validity, half-open use windows, non-expanding revalidation, and credential-free authorization timestamps. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. - Authenticated `originweave-tls` service-identity boundary that consumes an existing verified TCP stream, requires exact TLS-origin and transport-origin equality, derives RFC 9525 DNS or literal-IP reference identity only from the canonical HTTPS origin, validates WebPKI with explicit roots and fixed time, permits only TLS 1.2 and TLS 1.3, and never reconnects or resolves. - Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior and `NotConfigured` revocation evidence. From 9303e8f78a9ab09f43d95722503139ee3fc08b48 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 06:48:17 -0700 Subject: [PATCH 401/570] docs(evidence): record handle authority binding --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e715438e6..cb737a1b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Authenticated `originweave-tls` service-identity boundary that consumes an existing verified TCP stream, requires exact TLS-origin and transport-origin equality, derives RFC 9525 DNS or literal-IP reference identity only from the canonical HTTPS origin, validates WebPKI with explicit roots and fixed time, permits only TLS 1.2 and TLS 1.3, and never reconnects or resolves. - Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior and `NotConfigured` revocation evidence. - Credential-free TLS evidence containing canonical origin, TCP peers, reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration. -- Credential-free sensitive-handle lifecycle evidence records issuance, exclusive expiry, bounded uses, observed resolution count, and revocation without storing opaque handle tokens or protected values. +- Credential-free sensitive-handle lifecycle evidence binds issuance, exclusive expiry, bounded uses, observed resolution count, and revocation to the exact credential-free `OpaqueHandleOnly` sensitive-access receipt, preserving tenant, actor, task, field set, purpose, destination, classification, policy version, and decision time without storing opaque handle tokens or protected values. - Credential-free connection and redirect evidence containing canonical addresses, destination classes, target digests, hop numbers, and approved-address counts. - Credential-free verified TCP evidence containing the logical origin, requested socket, observed peer, destination class, successful attempt number, and per-attempt timeout. - Standard `Display` and `std::error::Error` contracts for destination, redirect, digest, direct-network, and TLS failures, including preserved destination-policy, rustls, and operating-system sources where applicable. From 6643516aae6574d0b780d0ff4d255d6ba6d38bd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 07:49:50 -0700 Subject: [PATCH 402/570] test(sensitive): cover revocation at exact expiry --- .../sensitive_handle_lifecycle_evidence.rs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs index f234abfa6..95034cecc 100644 --- a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs +++ b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs @@ -76,6 +76,22 @@ fn records_revocation_time_without_storing_revocation_payloads() -> TestResult { Ok(()) } +#[test] +fn records_revocation_at_exact_expiry_boundary() -> TestResult { + let mut input = valid_input()?; + input.revoked_epoch_seconds = Some(input.expires_epoch_seconds); + + let evidence = + SensitiveHandleLifecycleEvidence::try_from(input).map_err(|error| format!("{error:?}"))?; + + assert_eq!( + evidence.revoked_epoch_seconds(), + Some(evidence.expires_epoch_seconds()) + ); + assert!(evidence.is_revoked()); + Ok(()) +} + #[test] fn rejects_zero_or_non_increasing_handle_lifetime() -> TestResult { for (issued, expires) in [ @@ -113,8 +129,8 @@ fn rejects_zero_use_limit_or_resolution_count_above_limit() -> TestResult { } #[test] -fn rejects_revocation_outside_handle_lifetime() -> TestResult { - for revoked in [1_720_000_000, 1_720_000_301, 1_720_000_302] { +fn rejects_revocation_before_issue_or_after_expiry() -> TestResult { + for revoked in [1_720_000_000, 1_720_000_302] { let mut input = valid_input()?; input.revoked_epoch_seconds = Some(revoked); assert_eq!( From 604ed74ba5b3a54c2aa7155bf1379c8f10245a9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 07:53:05 -0700 Subject: [PATCH 403/570] fix(sensitive): retain revocation at expiry boundary --- .../originweave-evidence/src/sensitive_handle_lifecycle.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs index c67880bff..68b466e2f 100644 --- a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs +++ b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs @@ -28,6 +28,9 @@ pub struct SensitiveHandleLifecycleEvidenceInput { /// Number of broker resolutions already observed for the handle. pub resolution_count: u32, /// Trusted Unix epoch second when the handle was revoked, when applicable. + /// + /// A revocation recorded exactly at expiry is retained as a terminal audit + /// event even though it cannot extend or restore handle validity. pub revoked_epoch_seconds: Option, } @@ -57,7 +60,7 @@ impl TryFrom for SensitiveHandleLifecycle || input.maximum_uses == 0 || input.resolution_count > input.maximum_uses || input.revoked_epoch_seconds.is_some_and(|revoked| { - revoked < input.issued_epoch_seconds || revoked >= input.expires_epoch_seconds + revoked < input.issued_epoch_seconds || revoked > input.expires_epoch_seconds }) { return Err(SensitiveEvidenceError::InvalidLifecycle); From b203eb448754e62572c47d888ff48729cfb2970a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 10:22:26 -0700 Subject: [PATCH 404/570] test(release): cover canonical limitation acceptance path --- .../tests/release_acceptance_canonical_text.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance_canonical_text.rs b/crates/originweave-core/tests/release_acceptance_canonical_text.rs index a60af4c18..f7edb1ce6 100644 --- a/crates/originweave-core/tests/release_acceptance_canonical_text.rs +++ b/crates/originweave-core/tests/release_acceptance_canonical_text.rs @@ -1,5 +1,20 @@ use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; +#[test] +fn limitation_accepts_canonical_boundary_text() { + let limitation = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ) + .expect("canonical limitation text must remain accepted"); + + assert_eq!(limitation.unsupported_claim(), "linux_arm64"); + assert_eq!( + limitation.buyer_consequence(), + "Linux ARM64 is excluded from the support profile." + ); +} + #[test] fn limitation_rejects_surrounding_whitespace_that_changes_claim_identity() { for unsupported_claim in [" linux_arm64", "linux_arm64 ", "\tlinux_arm64"] { From d6673dad3a5cabadeadcb7f4e5cdddd8e989190a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 10:29:24 -0700 Subject: [PATCH 405/570] test(release): cover canonical limitation constructor branches --- .../release_acceptance_canonical_text.rs | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance_canonical_text.rs b/crates/originweave-core/tests/release_acceptance_canonical_text.rs index f7edb1ce6..678fa90c4 100644 --- a/crates/originweave-core/tests/release_acceptance_canonical_text.rs +++ b/crates/originweave-core/tests/release_acceptance_canonical_text.rs @@ -5,13 +5,28 @@ fn limitation_accepts_canonical_boundary_text() { let limitation = DeclaredLimitation::new( "linux_arm64", "Linux ARM64 is excluded from the support profile.", - ) - .expect("canonical limitation text must remain accepted"); + ); + + assert_eq!( + limitation + .as_ref() + .map(|value| (value.unsupported_claim(), value.buyer_consequence())), + Ok(( + "linux_arm64", + "Linux ARM64 is excluded from the support profile." + )) + ); +} - assert_eq!(limitation.unsupported_claim(), "linux_arm64"); +#[test] +fn limitation_rejects_empty_fields_for_the_canonical_string_input_shape() { + assert_eq!( + DeclaredLimitation::new("", "Linux ARM64 is excluded from the support profile."), + Err(ReleaseDecisionError::EmptyLimitationClaim), + ); assert_eq!( - limitation.buyer_consequence(), - "Linux ARM64 is excluded from the support profile." + DeclaredLimitation::new("linux_arm64", ""), + Err(ReleaseDecisionError::EmptyLimitationConsequence), ); } From 6affc40814cd8d542ae4a5bbfea0d8100851bf74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 10:35:44 -0700 Subject: [PATCH 406/570] test(release): close generic limitation coverage gaps --- .../tests/release_acceptance_unicode17.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance_unicode17.rs b/crates/originweave-core/tests/release_acceptance_unicode17.rs index 71f8a99cb..6db557872 100644 --- a/crates/originweave-core/tests/release_acceptance_unicode17.rs +++ b/crates/originweave-core/tests/release_acceptance_unicode17.rs @@ -2,6 +2,18 @@ use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionEr const UNICODE_17_DEFAULT_IGNORABLE_CODE_POINT_COUNT: usize = 4_174; +#[test] +fn generic_constructor_input_shapes_cover_fail_closed_empty_boundaries() { + assert_eq!( + DeclaredLimitation::new(String::new(), "Linux ARM64 is unsupported."), + Err(ReleaseDecisionError::EmptyLimitationClaim), + ); + assert_eq!( + DeclaredLimitation::new("linux_arm64", String::new()), + Err(ReleaseDecisionError::EmptyLimitationConsequence), + ); +} + #[test] fn limitation_rejects_unicode_17_default_ignorable_code_points() -> Result<(), &'static str> { // Unicode 17.0.0 DerivedCoreProperties.txt (2025-07-30), From 7d6741ecea3bddcadccb01b1f2605726a7b0c5a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:07:34 -0700 Subject: [PATCH 407/570] chore(tls): restore canonical newline after realignment --- crates/originweave-tls/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-tls/src/lib.rs b/crates/originweave-tls/src/lib.rs index e50a758ac..9024946f4 100644 --- a/crates/originweave-tls/src/lib.rs +++ b/crates/originweave-tls/src/lib.rs @@ -34,4 +34,4 @@ pub use revocation::{RevocationMaterialFreshness, RevocationMaterialFreshnessErr pub use trust::{ MAX_TRUST_ROOT_BYTES, MAX_TRUST_ROOT_COUNT, TrustBundleIdentifier, TrustRootBundle, }; -pub use validity::{LeafValidityHorizon, LeafValidityHorizonError}; \ No newline at end of file +pub use validity::{LeafValidityHorizon, LeafValidityHorizonError}; From bc2c1433f419a252bec90fc4088ac236ffca0e8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:37:29 -0700 Subject: [PATCH 408/570] test(release): cover generic limitation success paths --- .../tests/release_acceptance_unicode17.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance_unicode17.rs b/crates/originweave-core/tests/release_acceptance_unicode17.rs index 6db557872..c8cccbd38 100644 --- a/crates/originweave-core/tests/release_acceptance_unicode17.rs +++ b/crates/originweave-core/tests/release_acceptance_unicode17.rs @@ -8,10 +8,24 @@ fn generic_constructor_input_shapes_cover_fail_closed_empty_boundaries() { DeclaredLimitation::new(String::new(), "Linux ARM64 is unsupported."), Err(ReleaseDecisionError::EmptyLimitationClaim), ); + assert!( + DeclaredLimitation::new( + String::from("linux_arm64"), + "Linux ARM64 is unsupported." + ) + .is_ok() + ); assert_eq!( DeclaredLimitation::new("linux_arm64", String::new()), Err(ReleaseDecisionError::EmptyLimitationConsequence), ); + assert!( + DeclaredLimitation::new( + "linux_arm64", + String::from("Linux ARM64 is unsupported.") + ) + .is_ok() + ); } #[test] From a730423498782cff43e8ff2742777d4ba8f3d045 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:41:43 -0700 Subject: [PATCH 409/570] style(release): apply canonical rustfmt to coverage regression --- .../tests/release_acceptance_unicode17.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance_unicode17.rs b/crates/originweave-core/tests/release_acceptance_unicode17.rs index c8cccbd38..faf9369e4 100644 --- a/crates/originweave-core/tests/release_acceptance_unicode17.rs +++ b/crates/originweave-core/tests/release_acceptance_unicode17.rs @@ -9,22 +9,14 @@ fn generic_constructor_input_shapes_cover_fail_closed_empty_boundaries() { Err(ReleaseDecisionError::EmptyLimitationClaim), ); assert!( - DeclaredLimitation::new( - String::from("linux_arm64"), - "Linux ARM64 is unsupported." - ) - .is_ok() + DeclaredLimitation::new(String::from("linux_arm64"), "Linux ARM64 is unsupported.").is_ok() ); assert_eq!( DeclaredLimitation::new("linux_arm64", String::new()), Err(ReleaseDecisionError::EmptyLimitationConsequence), ); assert!( - DeclaredLimitation::new( - "linux_arm64", - String::from("Linux ARM64 is unsupported.") - ) - .is_ok() + DeclaredLimitation::new("linux_arm64", String::from("Linux ARM64 is unsupported.")).is_ok() ); } From 51c8ee82e6fd2fa0baf9e8ad76870f3c4e375580 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:47:06 -0700 Subject: [PATCH 410/570] test(release): cover generic success paths in owning test crate --- crates/originweave-core/tests/release_acceptance.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index dd4f5501f..14cd2f8e4 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -17,6 +17,16 @@ fn declared_limitation() -> Result { ) } +#[test] +fn generic_constructor_input_shapes_cover_success_paths_in_this_test_crate() { + assert!( + DeclaredLimitation::new(String::from("linux_arm64"), "Linux ARM64 is unsupported.").is_ok() + ); + assert!( + DeclaredLimitation::new("linux_arm64", String::from("Linux ARM64 is unsupported.")).is_ok() + ); +} + #[test] fn complete_passing_evidence_is_accepted_without_declared_limitations() -> Result<(), ReleaseDecisionError> { From 0a9bef98dc5a1e866207d8cf3999c0defeb9a83f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 12:39:05 -0700 Subject: [PATCH 411/570] test(release): cover canonical whitespace validation exits --- .../tests/release_acceptance_resource_bounds.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs index 489f2230b..36664773d 100644 --- a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs +++ b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs @@ -31,10 +31,18 @@ fn borrowed_limitation_text_covers_every_validation_exit() { DeclaredLimitation::new("", "bounded buyer consequence"), Err(ReleaseDecisionError::EmptyLimitationClaim) ); + assert_eq!( + DeclaredLimitation::new(" bounded_claim", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); assert_eq!( DeclaredLimitation::new("bounded_claim", ""), Err(ReleaseDecisionError::EmptyLimitationConsequence) ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "bounded buyer consequence "), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); assert_eq!( DeclaredLimitation::new("forged\nclaim", "bounded buyer consequence"), Err(ReleaseDecisionError::InvalidLimitationClaim) From 0744979eeb88d5524aa4602b0317eacaaa6cd9ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 13:34:30 -0700 Subject: [PATCH 412/570] test(core): reproduce non-NFC release limitation identity --- .../release_acceptance_canonical_text.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance_canonical_text.rs b/crates/originweave-core/tests/release_acceptance_canonical_text.rs index 678fa90c4..7fb4c171e 100644 --- a/crates/originweave-core/tests/release_acceptance_canonical_text.rs +++ b/crates/originweave-core/tests/release_acceptance_canonical_text.rs @@ -58,3 +58,38 @@ fn limitation_rejects_surrounding_whitespace_in_buyer_consequence() { ); } } + +#[test] +fn limitation_rejects_non_nfc_claim_identity() { + let nfc_claim = "caf\u{e9}"; + let canonically_equivalent_nfd_claim = "cafe\u{301}"; + + assert!( + DeclaredLimitation::new( + nfc_claim, + "This normalized claim remains a supported buyer-visible spelling.", + ) + .is_ok(), + "NFC international text must remain admissible", + ); + assert_eq!( + DeclaredLimitation::new( + canonically_equivalent_nfd_claim, + "This decomposed spelling must not create a second claim identity.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "canonically equivalent NFD text must not bypass limitation identity", + ); +} + +#[test] +fn limitation_rejects_non_nfc_buyer_consequence() { + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + "Cafe\u{301} support is excluded from this profile.", + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "buyer-visible consequences must use one canonical Unicode spelling", + ); +} From 952da8745b6a18fc2ea9423e725e748a06be55aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 13:43:53 -0700 Subject: [PATCH 413/570] fix(core): reject non-NFC release limitation text --- Cargo.lock | 27 +++++++++++++++++++ crates/originweave-core/Cargo.toml | 1 + .../src/release_acceptance.rs | 25 +++++++++++------ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e2ada3c4e..249eecc70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -266,6 +266,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "originweave-core" version = "0.1.0" +dependencies = [ + "unicode-normalization", +] [[package]] name = "originweave-destination" @@ -554,6 +557,21 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "typenum" version = "1.20.1" @@ -566,6 +584,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/crates/originweave-core/Cargo.toml b/crates/originweave-core/Cargo.toml index 517e41217..dcda2a6c4 100644 --- a/crates/originweave-core/Cargo.toml +++ b/crates/originweave-core/Cargo.toml @@ -14,6 +14,7 @@ publish = false path = "src/root.rs" [dependencies] +unicode-normalization = "=0.1.25" [lints] workspace = true diff --git a/crates/originweave-core/src/release_acceptance.rs b/crates/originweave-core/src/release_acceptance.rs index bb4e12870..352de14c3 100644 --- a/crates/originweave-core/src/release_acceptance.rs +++ b/crates/originweave-core/src/release_acceptance.rs @@ -6,6 +6,8 @@ use std::fmt; +use unicode_normalization::is_nfc; + /// Maximum UTF-8 byte length retained for either buyer-visible limitation field. pub const MAX_RELEASE_LIMITATION_TEXT_BYTES: usize = 1024; @@ -85,10 +87,11 @@ pub struct DeclaredLimitation { impl DeclaredLimitation { /// Construct one explicit buyer-visible release limitation. /// - /// Empty/whitespace-only values, surrounding whitespace, fields exceeding the - /// fixed UTF-8 byte budget, and ambiguous presentation characters fail closed - /// because they cannot safely represent one canonical, resource-bounded - /// buyer-visible release limitation. + /// Empty/whitespace-only values, surrounding whitespace, non-NFC Unicode, + /// fields exceeding the fixed UTF-8 byte budget, and ambiguous presentation + /// characters fail closed because they cannot safely represent one canonical, + /// resource-bounded buyer-visible release limitation. Accepted text is retained + /// byte-for-byte; this constructor never normalizes caller input implicitly. pub fn new( unsupported_claim: impl Into, buyer_consequence: impl Into, @@ -103,6 +106,9 @@ impl DeclaredLimitation { if unsupported_claim.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { return Err(ReleaseDecisionError::LimitationClaimTooLong); } + if !is_nfc(&unsupported_claim) { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } if unsupported_claim .chars() .any(disallowed_release_limitation_character) @@ -119,6 +125,9 @@ impl DeclaredLimitation { if buyer_consequence.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { return Err(ReleaseDecisionError::LimitationConsequenceTooLong); } + if !is_nfc(&buyer_consequence) { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } if buyer_consequence .chars() .any(disallowed_release_limitation_character) @@ -189,13 +198,13 @@ pub enum ReleaseDecisionError { EmptyLimitationClaim, /// A declared limitation claim exceeded the fixed UTF-8 byte budget. LimitationClaimTooLong, - /// A declared limitation claim contained an unsafe presentation character. + /// A declared limitation claim was not canonical NFC text or was presentation-unsafe. InvalidLimitationClaim, /// A declared limitation did not state the buyer-visible consequence. EmptyLimitationConsequence, /// A declared limitation consequence exceeded the fixed UTF-8 byte budget. LimitationConsequenceTooLong, - /// A declared limitation consequence contained an unsafe presentation character. + /// A limitation consequence was not canonical NFC text or was presentation-unsafe. InvalidLimitationConsequence, /// One release report supplied more buyer-visible limitations than the fixed resource budget. TooManyDeclaredLimitations, @@ -215,14 +224,14 @@ impl fmt::Display for ReleaseDecisionError { formatter.write_str("declared release limitation claim exceeds the byte budget") } Self::InvalidLimitationClaim => formatter.write_str( - "declared release limitation claim contains an unsafe presentation character", + "declared release limitation claim is not canonical NFC text or contains an unsafe presentation character", ), Self::EmptyLimitationConsequence => formatter .write_str("declared release limitation must state a buyer-visible consequence"), Self::LimitationConsequenceTooLong => formatter .write_str("declared release limitation consequence exceeds the byte budget"), Self::InvalidLimitationConsequence => formatter.write_str( - "declared release limitation consequence contains an unsafe presentation character", + "declared release limitation consequence is not canonical NFC text or contains an unsafe presentation character", ), Self::TooManyDeclaredLimitations => formatter .write_str("benchmark release decision contains too many declared limitations"), From 8f86ae1bf72fb3a0daa2a74ba9a033600633e69e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 13:48:53 -0700 Subject: [PATCH 414/570] fix(core): preserve limitation error compatibility --- crates/originweave-core/src/release_acceptance.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/release_acceptance.rs b/crates/originweave-core/src/release_acceptance.rs index 352de14c3..fb3cef0e5 100644 --- a/crates/originweave-core/src/release_acceptance.rs +++ b/crates/originweave-core/src/release_acceptance.rs @@ -224,14 +224,14 @@ impl fmt::Display for ReleaseDecisionError { formatter.write_str("declared release limitation claim exceeds the byte budget") } Self::InvalidLimitationClaim => formatter.write_str( - "declared release limitation claim is not canonical NFC text or contains an unsafe presentation character", + "declared release limitation claim contains an unsafe presentation character", ), Self::EmptyLimitationConsequence => formatter .write_str("declared release limitation must state a buyer-visible consequence"), Self::LimitationConsequenceTooLong => formatter .write_str("declared release limitation consequence exceeds the byte budget"), Self::InvalidLimitationConsequence => formatter.write_str( - "declared release limitation consequence is not canonical NFC text or contains an unsafe presentation character", + "declared release limitation consequence contains an unsafe presentation character", ), Self::TooManyDeclaredLimitations => formatter .write_str("benchmark release decision contains too many declared limitations"), From 3a155e4662c0d5497533425025ca77d70cc511e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 13:53:22 -0700 Subject: [PATCH 415/570] test(core): cover NFC rejection exits in exact coverage --- .../tests/release_acceptance_resource_bounds.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs index 36664773d..8116e96c4 100644 --- a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs +++ b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs @@ -35,6 +35,10 @@ fn borrowed_limitation_text_covers_every_validation_exit() { DeclaredLimitation::new(" bounded_claim", "bounded buyer consequence"), Err(ReleaseDecisionError::InvalidLimitationClaim) ); + assert_eq!( + DeclaredLimitation::new("cafe\u{301}", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); assert_eq!( DeclaredLimitation::new("bounded_claim", ""), Err(ReleaseDecisionError::EmptyLimitationConsequence) @@ -43,6 +47,10 @@ fn borrowed_limitation_text_covers_every_validation_exit() { DeclaredLimitation::new("bounded_claim", "bounded buyer consequence "), Err(ReleaseDecisionError::InvalidLimitationConsequence) ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "cafe\u{301} buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); assert_eq!( DeclaredLimitation::new("forged\nclaim", "bounded buyer consequence"), Err(ReleaseDecisionError::InvalidLimitationClaim) @@ -87,4 +95,4 @@ fn release_resource_limit_errors_have_deterministic_standard_error_contracts() { let standard_error: &dyn std::error::Error = &error; assert!(standard_error.source().is_none()); } -} +} \ No newline at end of file From ad149e357c94a4ec050e5452be2f81b82f9b4b2e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 13:54:34 -0700 Subject: [PATCH 416/570] docs: record release limitation NFC contract --- docs/doctoring.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/doctoring.md b/docs/doctoring.md index 375b82fa9..64e362bf3 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -28,6 +28,8 @@ RFC 9700 is the current Best Current Practice for OAuth 2.0 security. It require Unicode 17.0 defines `Default_Ignorable_Code_Point` in the Unicode Character Database and records the exact derived set in the versioned `DerivedCoreProperties.txt` data file. Those characters can be invisible or alter presentation without supplying an ordinary visible glyph. OriginWeave therefore treats the Unicode 17.0 derived property as a pinned presentation-safety input for buyer-visible release-limitation metadata, in addition to rejecting control characters and non-canonical leading or trailing whitespace. The admitted text is not silently normalized: accepted content retains its exact bytes, while ambiguous presentation characters and surrounding whitespace fail closed so one release claim cannot acquire multiple stored spellings. This is a bounded metadata-identity policy, not a claim of complete Unicode spoofing resistance or semantic text equivalence. +Unicode Standard Annex #15, revision 57 for Unicode 17.0.0, defines canonical equivalence and NFC and states that normalized equivalent strings have a unique binary representation. A release limitation is an identity-bearing buyer artifact, so OriginWeave rejects canonically equivalent non-NFC spellings instead of silently rewriting them. The production boundary uses only `unicode_normalization::is_nfc`; accepted strings remain byte-for-byte caller input. Rust's standard library does not provide Unicode normalization, so `unicode-normalization` is pinned exactly to 0.1.25. The reviewed crate implements UAX #15 normalization, declares Rust 1.36+ compatibility (below OriginWeave's Rust 1.97.1 baseline), is dual MIT/Apache-2.0 licensed, and adds only `tinyvec`/`tinyvec_macros` transitively in this workspace lockfile. The dependency is narrow, deterministic, non-networked, and maintained through the existing locked-dependency/security-scan process; any future Unicode-version or crate-version movement requires renewed normalization and supply-chain review. + ### Resolved destination and redirect safety Canonical origin identity is not a network-destination authorization. The IANA IPv4 and IPv6 Special-Purpose Address Space registries enumerate blocks whose source, destination, forwardability, globally reachable, and protocol-reserved properties differ. Both registries were last updated on 9 October 2025 and explicitly warn that registry presence does not guarantee routability in a particular local or global context. RFC 6890 established the common special-purpose registry fields, and RFC 8190 replaced the ambiguous `global` field with `globally reachable`. @@ -180,6 +182,10 @@ The Rust Project Developers. (2026). *TcpStream in std::net* (Rust 1.97.1) [Soft The Unicode Consortium. (2025). *DerivedCoreProperties-17.0.0.txt* [Data file]. https://www.unicode.org/Public/17.0.0/ucd/DerivedCoreProperties.txt +The Unicode Consortium. (2025, July 30). *Unicode Standard Annex #15: Unicode normalization forms* (Revision 57, Unicode 17.0.0). https://www.unicode.org/reports/tr15/ + +Unicode-RS Project Developers. (2025). *unicode-normalization 0.1.25* [Computer software]. https://docs.rs/unicode-normalization/0.1.25/unicode_normalization/ + Web Hypertext Application Technology Working Group. (2026). *URL standard*. https://url.spec.whatwg.org/ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ From cd33b3befabf2cf2efdd8c375d4293dc4408119b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 15:05:58 -0700 Subject: [PATCH 417/570] test(core): restore canonical release acceptance formatting --- .../tests/release_acceptance_resource_bounds.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs index 8116e96c4..fd45e0e6d 100644 --- a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs +++ b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs @@ -95,4 +95,4 @@ fn release_resource_limit_errors_have_deterministic_standard_error_contracts() { let standard_error: &dyn std::error::Error = &error; assert!(standard_error.source().is_none()); } -} \ No newline at end of file +} From 6a17e5f8e29f77a08bfee0983ad680da8b863e61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 18:23:26 -0700 Subject: [PATCH 418/570] test(docs): pin RFC 5280 author identity --- tests/test_doctoring_reference_contract.py | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/test_doctoring_reference_contract.py diff --git a/tests/test_doctoring_reference_contract.py b/tests/test_doctoring_reference_contract.py new file mode 100644 index 000000000..bdeded44f --- /dev/null +++ b/tests/test_doctoring_reference_contract.py @@ -0,0 +1,28 @@ +"""Regression contracts for standards references that bind OriginWeave design claims.""" + +from __future__ import annotations + +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DOCTORING = ROOT / "docs" / "doctoring.md" + + +class DoctoringReferenceContractTests(unittest.TestCase): + """Keep cited primary-standard authorship aligned with the canonical source.""" + + def test_rfc_5280_reference_uses_canonical_author_initials(self) -> None: + """RFC 5280 must credit Sharon Boeyen as S. Boeyen, matching RFC Editor metadata.""" + text = DOCTORING.read_text(encoding="utf-8") + expected = ( + "Cooper, D., Santesson, S., Farrell, S., Boeyen, S., Housley, R., & Polk, W. " + "(2008). *Internet X.509 public key infrastructure certificate and certificate " + "revocation list (CRL) profile* (RFC 5280). Internet Engineering Task Force. " + "https://doi.org/10.17487/RFC5280" + ) + self.assertIn(expected, text) + + +if __name__ == "__main__": + unittest.main() From b35d739017aa5d361b605be48045be50b5a35f6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 18:26:19 -0700 Subject: [PATCH 419/570] fix(docs): correct RFC 5280 author identity --- docs/doctoring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/doctoring.md b/docs/doctoring.md index aa2d1efc8..fcd9dd4f0 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -120,7 +120,7 @@ Chromium Authors. (n.d.). *Proxy support in Chrome* [Source documentation]. Chro Chromium Authors. (2026). *URL canonicalizer unit tests* [Source code]. Chromium. https://chromium.googlesource.com/chromium/src/+/446d05d21720f0b3505ec21057b3e9f909784262/url/url_canon_unittest.cc -Cooper, D., Santesson, S., Farrell, S., Boeyen, R., Housley, R., & Polk, W. (2008). *Internet X.509 public key infrastructure certificate and certificate revocation list (CRL) profile* (RFC 5280). Internet Engineering Task Force. https://doi.org/10.17487/RFC5280 +Cooper, D., Santesson, S., Farrell, S., Boeyen, S., Housley, R., & Polk, W. (2008). *Internet X.509 public key infrastructure certificate and certificate revocation list (CRL) profile* (RFC 5280). Internet Engineering Task Force. https://doi.org/10.17487/RFC5280 Cotton, M., Vegoda, L., Bonica, R., & Haberman, B. (2013). *Special-purpose IP address registries* (RFC 6890). Internet Engineering Task Force. https://doi.org/10.17487/RFC6890 From 5568cce8196d2663e7fff25c8212f212a7c172c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 20:08:15 -0700 Subject: [PATCH 420/570] test(core): expose misleading limitation diagnostics --- .../release_acceptance_canonical_text.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance_canonical_text.rs b/crates/originweave-core/tests/release_acceptance_canonical_text.rs index 7fb4c171e..f141baed0 100644 --- a/crates/originweave-core/tests/release_acceptance_canonical_text.rs +++ b/crates/originweave-core/tests/release_acceptance_canonical_text.rs @@ -93,3 +93,26 @@ fn limitation_rejects_non_nfc_buyer_consequence() { "buyer-visible consequences must use one canonical Unicode spelling", ); } + +#[test] +fn invalid_canonical_text_errors_describe_all_rejected_causes() { + let claim_error = DeclaredLimitation::new( + " linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ) + .expect_err("surrounding claim whitespace must remain invalid"); + assert_eq!( + claim_error.to_string(), + "declared release limitation claim is not canonical or contains an unsafe presentation character" + ); + + let consequence_error = DeclaredLimitation::new( + "linux_arm64", + "Cafe\u{301} support is excluded from this profile.", + ) + .expect_err("non-NFC consequence text must remain invalid"); + assert_eq!( + consequence_error.to_string(), + "declared release limitation consequence is not canonical or contains an unsafe presentation character" + ); +} From 68afef30964d2d9e4f6e4499b8b9e2db4003a748 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 20:11:05 -0700 Subject: [PATCH 421/570] fix(core): make limitation diagnostics match validation --- crates/originweave-core/src/release_acceptance.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/release_acceptance.rs b/crates/originweave-core/src/release_acceptance.rs index fb3cef0e5..a7db2a760 100644 --- a/crates/originweave-core/src/release_acceptance.rs +++ b/crates/originweave-core/src/release_acceptance.rs @@ -224,14 +224,14 @@ impl fmt::Display for ReleaseDecisionError { formatter.write_str("declared release limitation claim exceeds the byte budget") } Self::InvalidLimitationClaim => formatter.write_str( - "declared release limitation claim contains an unsafe presentation character", + "declared release limitation claim is not canonical or contains an unsafe presentation character", ), Self::EmptyLimitationConsequence => formatter .write_str("declared release limitation must state a buyer-visible consequence"), Self::LimitationConsequenceTooLong => formatter .write_str("declared release limitation consequence exceeds the byte budget"), Self::InvalidLimitationConsequence => formatter.write_str( - "declared release limitation consequence contains an unsafe presentation character", + "declared release limitation consequence is not canonical or contains an unsafe presentation character", ), Self::TooManyDeclaredLimitations => formatter .write_str("benchmark release decision contains too many declared limitations"), From 473a22e32d2cd11f6fa17faa9c6ddf57115641b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 20:11:44 -0700 Subject: [PATCH 422/570] test(core): align limitation error contract --- crates/originweave-core/tests/release_acceptance.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs index 14cd2f8e4..3e37fab18 100644 --- a/crates/originweave-core/tests/release_acceptance.rs +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -138,7 +138,7 @@ fn limitation_errors_have_deterministic_standard_error_contracts() { ), ( ReleaseDecisionError::InvalidLimitationClaim, - "declared release limitation claim contains an unsafe presentation character", + "declared release limitation claim is not canonical or contains an unsafe presentation character", ), ( ReleaseDecisionError::EmptyLimitationConsequence, @@ -146,7 +146,7 @@ fn limitation_errors_have_deterministic_standard_error_contracts() { ), ( ReleaseDecisionError::InvalidLimitationConsequence, - "declared release limitation consequence contains an unsafe presentation character", + "declared release limitation consequence is not canonical or contains an unsafe presentation character", ), ( ReleaseDecisionError::DuplicateLimitationClaim, From 1d1ed877ce485bf11366cd9f0bf981d5241b8f85 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 20:14:14 -0700 Subject: [PATCH 423/570] test(core): keep diagnostic regression clippy-clean --- .../tests/release_acceptance_canonical_text.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/crates/originweave-core/tests/release_acceptance_canonical_text.rs b/crates/originweave-core/tests/release_acceptance_canonical_text.rs index f141baed0..2d7840af3 100644 --- a/crates/originweave-core/tests/release_acceptance_canonical_text.rs +++ b/crates/originweave-core/tests/release_acceptance_canonical_text.rs @@ -96,23 +96,21 @@ fn limitation_rejects_non_nfc_buyer_consequence() { #[test] fn invalid_canonical_text_errors_describe_all_rejected_causes() { - let claim_error = DeclaredLimitation::new( + let claim_result = DeclaredLimitation::new( " linux_arm64", "Linux ARM64 is excluded from the support profile.", - ) - .expect_err("surrounding claim whitespace must remain invalid"); + ); assert_eq!( - claim_error.to_string(), - "declared release limitation claim is not canonical or contains an unsafe presentation character" + claim_result.as_ref().map_err(ToString::to_string), + Err("declared release limitation claim is not canonical or contains an unsafe presentation character".to_owned()) ); - let consequence_error = DeclaredLimitation::new( + let consequence_result = DeclaredLimitation::new( "linux_arm64", "Cafe\u{301} support is excluded from this profile.", - ) - .expect_err("non-NFC consequence text must remain invalid"); + ); assert_eq!( - consequence_error.to_string(), - "declared release limitation consequence is not canonical or contains an unsafe presentation character" + consequence_result.as_ref().map_err(ToString::to_string), + Err("declared release limitation consequence is not canonical or contains an unsafe presentation character".to_owned()) ); } From e8c63a907a5b6000584b491171e3e414586bfb68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 20:34:57 -0700 Subject: [PATCH 424/570] test(core): pin release line-separator rejection --- .../tests/release_acceptance_unicode17.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance_unicode17.rs b/crates/originweave-core/tests/release_acceptance_unicode17.rs index faf9369e4..da3efef1b 100644 --- a/crates/originweave-core/tests/release_acceptance_unicode17.rs +++ b/crates/originweave-core/tests/release_acceptance_unicode17.rs @@ -77,6 +77,28 @@ fn limitation_rejects_unicode_17_default_ignorable_code_points() -> Result<(), & Ok(()) } +#[test] +fn limitation_rejects_line_and_paragraph_separators_beyond_default_ignorable_set() { + for (name, separator) in [('U+2028', '\u{2028}'), ('U+2029', '\u{2029}')] { + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{separator}forged_release_claim"), + "Linux ARM64 is unsupported.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "{name} must be rejected in the unsupported claim to prevent line-forging ambiguity", + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{separator}forged_release_consequence"), + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "{name} must be rejected in the buyer consequence to prevent line-forging ambiguity", + ); + } +} + #[test] fn limitation_does_not_blanket_reject_unicode_17_whitespace() -> Result<(), ReleaseDecisionError> { let medium_mathematical_space = '\u{205f}'; From eac2014bf0e642953bed2c71e5fe963900b22286 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 20:41:09 -0700 Subject: [PATCH 425/570] test(core): fix release separator test labels --- crates/originweave-core/tests/release_acceptance_unicode17.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-core/tests/release_acceptance_unicode17.rs b/crates/originweave-core/tests/release_acceptance_unicode17.rs index da3efef1b..eccd90e89 100644 --- a/crates/originweave-core/tests/release_acceptance_unicode17.rs +++ b/crates/originweave-core/tests/release_acceptance_unicode17.rs @@ -79,7 +79,7 @@ fn limitation_rejects_unicode_17_default_ignorable_code_points() -> Result<(), & #[test] fn limitation_rejects_line_and_paragraph_separators_beyond_default_ignorable_set() { - for (name, separator) in [('U+2028', '\u{2028}'), ('U+2029', '\u{2029}')] { + for (name, separator) in [("U+2028", '\u{2028}'), ("U+2029", '\u{2029}')] { assert_eq!( DeclaredLimitation::new( format!("linux_arm64{separator}forged_release_claim"), From 0c071a865adbc949e897ecaab9d3afe3a6143d38 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:31:02 -0700 Subject: [PATCH 426/570] test(sensitive): bind handle expiry to retention deadline --- .../tests/sensitive_handle_access_binding.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs index e773a70e5..6dbf8d713 100644 --- a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs +++ b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs @@ -91,3 +91,24 @@ fn lifecycle_rejects_issuance_before_policy_decision() -> TestResult { ); Ok(()) } + +#[test] +fn lifecycle_expiry_respects_access_retention_deadline() -> TestResult { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; + let retention_deadline = access + .retention_deadline_epoch_seconds() + .ok_or_else(|| "fixture must carry a retention deadline".to_owned())?; + + let mut exact_deadline = lifecycle_input(access.clone(), 1_720_000_001); + exact_deadline.expires_epoch_seconds = retention_deadline; + SensitiveHandleLifecycleEvidence::try_from(exact_deadline) + .map_err(|error| format!("{error:?}"))?; + + let mut after_deadline = lifecycle_input(access, 1_720_000_001); + after_deadline.expires_epoch_seconds = retention_deadline + 1; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(after_deadline), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} From 4b1a99a0e0373fd232f8ebb5dc77e0e144642312 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:32:40 -0700 Subject: [PATCH 427/570] fix(sensitive): cap handle lifetime at retention deadline --- .../src/sensitive_handle_lifecycle.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs index 68b466e2f..f61c8527f 100644 --- a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs +++ b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs @@ -14,7 +14,9 @@ use crate::sensitive_access::{ /// /// The embedded access receipt binds the lifecycle to the tenant, actor, task, /// field set, purpose, destination, classification, policy version, and exact -/// opaque-handle authorization without carrying protected values. +/// opaque-handle authorization without carrying protected values. When the access +/// receipt carries a retention deadline, the handle must expire no later than +/// that deadline so derived opaque authority cannot outlive its governing receipt. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SensitiveHandleLifecycleEvidenceInput { /// Credential-free access receipt that authorized this opaque handle. @@ -22,6 +24,9 @@ pub struct SensitiveHandleLifecycleEvidenceInput { /// Trusted Unix epoch second when the handle was issued. pub issued_epoch_seconds: u64, /// Trusted Unix epoch second after which the handle is no longer valid. + /// + /// When the retained access receipt defines a retention deadline, this value + /// may equal but must not exceed that deadline. pub expires_epoch_seconds: u64, /// Maximum number of broker resolutions authorized for the handle. pub maximum_uses: u32, @@ -39,6 +44,7 @@ pub struct SensitiveHandleLifecycleEvidenceInput { /// The value retains the exact credential-free sensitive-access receipt that /// authorized opaque-handle use, but deliberately excludes both the opaque /// handle token and the secret or protected value that the broker can resolve. +/// Any receipt retention deadline also bounds the derived handle lifetime. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SensitiveHandleLifecycleEvidence { access_evidence: SensitiveAccessEvidence, @@ -57,6 +63,10 @@ impl TryFrom for SensitiveHandleLifecycle || input.issued_epoch_seconds == 0 || input.issued_epoch_seconds < input.access_evidence.decision_epoch_seconds() || input.expires_epoch_seconds <= input.issued_epoch_seconds + || input + .access_evidence + .retention_deadline_epoch_seconds() + .is_some_and(|deadline| input.expires_epoch_seconds > deadline) || input.maximum_uses == 0 || input.resolution_count > input.maximum_uses || input.revoked_epoch_seconds.is_some_and(|revoked| { From be893e96909745e358e68e4716e98a9d11a65fdf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 13:27:28 -0700 Subject: [PATCH 428/570] test(destination): prove post-expiry revalidation authority --- .../resolution_post_expiry_revalidation.rs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs diff --git a/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs b/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs new file mode 100644 index 000000000..3c8443554 --- /dev/null +++ b/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs @@ -0,0 +1,78 @@ +#![allow(clippy::expect_used)] + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use originweave_core::Origin; +use originweave_destination::{DestinationError, DestinationPolicy, FreshResolutionSnapshot}; + +fn origin() -> Origin { + Origin::parse("https://example.com").expect("test origin must parse") +} + +fn ipv4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) +} + +#[test] +fn post_expiry_revalidation_establishes_new_authority_without_reviving_the_old_snapshot() { + let first = ipv4(8, 8, 8, 8); + let second = ipv4(1, 1, 1, 1); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin(), + [first, second], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial bounded freshness authority"); + + let expiry = Duration::from_secs(14); + assert_eq!( + snapshot.authorize_connection(first, expiry), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: expiry, + current_time: expiry, + }) + ); + + let refreshed = snapshot + .revalidate([second], &policy, expiry) + .expect("fresh non-expanding validation may establish a new bounded snapshot"); + assert_eq!(refreshed.approved_at(), expiry); + assert_eq!(refreshed.valid_until(), Duration::from_secs(18)); + refreshed + .authorize_connection(second, expiry) + .expect("the newly validated snapshot has independent current authority"); + + assert_eq!( + snapshot.authorize_connection(second, expiry), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: expiry, + current_time: expiry, + }) + ); +} + +#[test] +fn post_expiry_revalidation_still_rejects_address_set_expansion() { + let approved = ipv4(8, 8, 8, 8); + let unexpected = ipv4(9, 9, 9, 9); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin(), + [approved], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial bounded freshness authority"); + + assert_eq!( + snapshot.revalidate([approved, unexpected], &policy, Duration::from_secs(14)), + Err(DestinationError::ResolutionSetExpanded { + address: unexpected, + }) + ); +} From 3ba836a5af4779c6e9ff801493b82b605bd047d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 14:11:42 -0700 Subject: [PATCH 429/570] test(network): require live locateNodes authority binding --- ...ver_bidi_locate_nodes_current_authority.rs | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs new file mode 100644 index 000000000..3e622325f --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs @@ -0,0 +1,261 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserSessionId, BrowsingContextId, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type ServerHandle = thread::JoinHandle>>; +type EstablishedFixture = + Result<(SocketAddr, WebDriverBiDiWebSocketEstablished, ServerHandle), Box>; + +fn connect(endpoint: &str) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + Ok(request) +} + +fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one masked final client text frame", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture rejects 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn establish() -> EstablishedFixture { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + let request = read_opening_request(&mut stream)?; + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client opening request was incomplete", + )); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let command = read_client_text_frame(&mut stream)?; + let response = RESPONSE_DOCUMENT.as_bytes(); + let response_length = u8::try_from(response.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "test response exceeds short frame") + })?; + if response_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeds short frame", + )); + } + stream.write_all(&[0x81, response_length])?; + stream.write_all(response)?; + Ok(command) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((local_addr, established, server)) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + origin: &'a Origin, +) -> Result< + ( + BrowserContextOriginEpochDispatchTarget<'a>, + BrowserSessionId, + BrowsingContextId, + ), + Box, +> { + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, origin)?; + Ok(( + BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + origin, + ), + epoch, + ), + session, + context, + )) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +#[test] +fn live_websocket_locate_nodes_exchange_binds_wire_nodes_to_current_authority() +-> Result<(), Box> { + let (local_addr, established, server) = establish()?; + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let (target, _session, _context) = current_target(&mut registry, &origin)?; + let expected_epoch = target.expected_epoch(); + + let (established, handles) = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + semantic_observation_proof()?, + &mut registry, + target, + )?; + + assert_eq!(handles.len(), 1); + assert_eq!(handles[0].origin(), &origin); + assert_eq!(handles[0].document_epoch(), expected_epoch); + assert_eq!( + established + .transport_evidence() + .verified_peer() + .socket_addr(), + local_addr + ); + let command = server.join().map_err(|_| "test server panicked")??; + assert_eq!(command, locate_nodes_command()?.as_json().as_bytes()); + Ok(()) +} + +#[test] +fn live_exchange_fails_closed_when_document_epoch_changed_before_wire_node_binding() +-> Result<(), Box> { + let (_local_addr, established, server) = establish()?; + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let (target, session, context) = current_target(&mut registry, &origin)?; + let stale_epoch = target.expected_epoch(); + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin(session, context, &origin)?; + + let error = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + semantic_observation_proof()?, + &mut registry, + target, + ); + + assert!(matches!( + error, + Err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected, + current, + }, + ), + )) if expected == stale_epoch && current == current_epoch + )); + let command = server.join().map_err(|_| "test server panicked")??; + assert_eq!(command, locate_nodes_command()?.as_json().as_bytes()); + Ok(()) +} From b22a371fb8fe90d1c748a872957abdcd7a7ba04f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 14:14:38 -0700 Subject: [PATCH 430/570] test(network): format live authority regression --- .../webdriver_bidi_locate_nodes_current_authority.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs index 3e622325f..ba4f9653a 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs @@ -34,7 +34,9 @@ type ServerHandle = thread::JoinHandle>>; type EstablishedFixture = Result<(SocketAddr, WebDriverBiDiWebSocketEstablished, ServerHandle), Box>; -fn connect(endpoint: &str) -> Result> { +fn connect( + endpoint: &str, +) -> Result> { let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; let correlated = admitted.correlate_session_id(SESSION_ID)?; let target = correlated.into_explicit_connect_target()?; @@ -109,7 +111,10 @@ fn establish() -> EstablishedFixture { let command = read_client_text_frame(&mut stream)?; let response = RESPONSE_DOCUMENT.as_bytes(); let response_length = u8::try_from(response.len()).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidData, "test response exceeds short frame") + io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeds short frame", + ) })?; if response_length > 125 { return Err(io::Error::new( From 082a8e288742f1adeff00fbfcf7be8daa4137567 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 14:18:29 -0700 Subject: [PATCH 431/570] feat(network): bind live BiDi nodes to current authority --- .../webdriver_bidi_locate_nodes_exchange.rs | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index d7c15f2ca..049598fb0 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -5,9 +5,10 @@ use std::{ }; use originweave_core::{ - BoundedWebDriverBiDiResponseDocument, ValidatedWebDriverBiDiLocateNodesResult, - WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, - WebDriverBiDiResponseDocumentAdmissionError, + BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, + BrowserContextOriginEpochDispatchTarget, ObservedNodeHandle, ValidatedBrowserProtocolUse, + ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, }; use crate::{ @@ -241,6 +242,47 @@ impl WebDriverBiDiWebSocketEstablished { } } } + + /// Exchange `locateNodes` and bind the exact wire-derived nodes to current browser authority. + /// + /// This is the live transport composition boundary for semantic node observation. The bounded + /// command is exchanged on the already peer-verified WebSocket using [`Self::exchange_locate_nodes`]. + /// Only after exact wire parsing and command correlation succeed does the method revalidate the + /// caller's reviewed WebDriver BiDi `SemanticObservation` proof plus the exact current + /// session/context/origin/document epoch through + /// [`ValidatedWebDriverBiDiLocateNodesResult::bind_current_nodes`]. No raw node identifier can be + /// substituted between the wire response and authority binding. + /// + /// A binding failure consumes this transport result and returns no reusable stream or node + /// handle, so a navigation or authority change observed after command construction cannot be + /// converted into stale node authority. Success returns only current [`ObservedNodeHandle`] + /// values together with the same established peer-verified stream. It still does not authorize + /// typed input, execute an action, or prove a post-condition. + pub fn exchange_locate_nodes_and_bind_current_nodes( + self, + command: WebDriverBiDiLocateNodesCommand, + command_masking_key: WebDriverBiDiWebSocketMaskKey, + next_pong_key: &mut dyn FnMut() -> Option, + exchange_timeout: Duration, + validated: ValidatedBrowserProtocolUse, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + ) -> Result<(Self, Vec), WebDriverBiDiLocateNodesExchangeError> { + let (established, result) = self.exchange_locate_nodes( + command, + command_masking_key, + next_pong_key, + exchange_timeout, + )?; + let handles = result + .bind_current_nodes(validated, authority_registry, target) + .map_err(|error| { + WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), + ) + })?; + Ok((established, handles)) + } } #[cfg(test)] From 217e070ac387453785d84d4b498d54053be52f16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 14:22:49 -0700 Subject: [PATCH 432/570] refactor(network): bundle current BiDi authority input --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 049598fb0..d0e39727c 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -248,8 +248,8 @@ impl WebDriverBiDiWebSocketEstablished { /// This is the live transport composition boundary for semantic node observation. The bounded /// command is exchanged on the already peer-verified WebSocket using [`Self::exchange_locate_nodes`]. /// Only after exact wire parsing and command correlation succeed does the method revalidate the - /// caller's reviewed WebDriver BiDi `SemanticObservation` proof plus the exact current - /// session/context/origin/document epoch through + /// reviewed WebDriver BiDi `SemanticObservation` proof and exact current + /// session/context/origin/document epoch carried together in `authority` through /// [`ValidatedWebDriverBiDiLocateNodesResult::bind_current_nodes`]. No raw node identifier can be /// substituted between the wire response and authority binding. /// @@ -264,10 +264,13 @@ impl WebDriverBiDiWebSocketEstablished { command_masking_key: WebDriverBiDiWebSocketMaskKey, next_pong_key: &mut dyn FnMut() -> Option, exchange_timeout: Duration, - validated: ValidatedBrowserProtocolUse, + authority: ( + ValidatedBrowserProtocolUse, + BrowserContextOriginEpochDispatchTarget<'_>, + ), authority_registry: &mut BrowserAuthorityRegistry, - target: BrowserContextOriginEpochDispatchTarget<'_>, ) -> Result<(Self, Vec), WebDriverBiDiLocateNodesExchangeError> { + let (validated, target) = authority; let (established, result) = self.exchange_locate_nodes( command, command_masking_key, From 8ead5b9c6c8fb34299ab626d24a0a065285d2764 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 14:23:53 -0700 Subject: [PATCH 433/570] test(network): pass bundled live BiDi authority --- .../tests/webdriver_bidi_locate_nodes_current_authority.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs index ba4f9653a..2c91dd6bc 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_current_authority.rs @@ -208,9 +208,8 @@ fn live_websocket_locate_nodes_exchange_binds_wire_nodes_to_current_authority() WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), &mut || None, Duration::from_millis(500), - semantic_observation_proof()?, + (semantic_observation_proof()?, target), &mut registry, - target, )?; assert_eq!(handles.len(), 1); @@ -244,9 +243,8 @@ fn live_exchange_fails_closed_when_document_epoch_changed_before_wire_node_bindi WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), &mut || None, Duration::from_millis(500), - semantic_observation_proof()?, + (semantic_observation_proof()?, target), &mut registry, - target, ); assert!(matches!( From 6a82023ad30d84004aacafdd8afec041f590f940 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 16:40:02 -0700 Subject: [PATCH 434/570] test(network): cover live bind exchange failures --- ...i_locate_nodes_binding_exchange_failure.rs | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs new file mode 100644 index 000000000..e0c0ac43a --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -0,0 +1,191 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + request.extend_from_slice(&buffer[..count]); + } + Ok(request) +} + +fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one masked final client text frame", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture rejects 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn establish_with_unexpected_binary_response() -> Result< + ( + originweave_network::WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>>, + ), + Box, +> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + let request = read_opening_request(&mut stream)?; + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client opening request was incomplete", + )); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let command = read_client_text_frame(&mut stream)?; + stream.write_all(&[0x82, 0x00])?; + Ok(command) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +#[test] +fn live_binding_wrapper_fails_closed_when_wire_exchange_fails_before_binding() +-> Result<(), Box> { + let (established, server) = establish_with_unexpected_binary_response()?; + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + + let error = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + (semantic_observation_proof()?, target), + &mut registry, + ); + + assert!(matches!( + error, + Err(WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: true, + opcode: 0x2, + }) + )); + let command = server.join().map_err(|_| "test server panicked")??; + assert_eq!(command, locate_nodes_command()?.as_json().as_bytes()); + Ok(()) +} From 79fd48063e692b7d899ffb8347499791c3ecbfe4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 16:41:40 -0700 Subject: [PATCH 435/570] style(network): apply canonical exchange regression formatting --- ...river_bidi_locate_nodes_binding_exchange_failure.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs index e0c0ac43a..84a968f37 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -180,10 +180,12 @@ fn live_binding_wrapper_fails_closed_when_wire_exchange_fails_before_binding() assert!(matches!( error, - Err(WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { - fin: true, - opcode: 0x2, - }) + Err( + WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: true, + opcode: 0x2, + } + ) )); let command = server.join().map_err(|_| "test server panicked")??; assert_eq!(command, locate_nodes_command()?.as_json().as_bytes()); From 437237bdbad598213b25e284192c76112197e64b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 17:05:01 -0700 Subject: [PATCH 436/570] test(network): simplify BiDi failure fixture type --- ..._bidi_locate_nodes_binding_exchange_failure.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs index 84a968f37..1b8e58d74 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -27,6 +27,12 @@ const ADAPTER_VERSION: &str = "originweave-bidi-v1"; const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; const BROWSER_REVISION: &str = "chromium-r1639810"; +type UnexpectedBinaryServer = thread::JoinHandle>>; +type EstablishedWithUnexpectedBinaryServer = ( + originweave_network::WebDriverBiDiWebSocketEstablished, + UnexpectedBinaryServer, +); + fn connect( endpoint: &str, ) -> Result> { @@ -86,13 +92,8 @@ fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { Ok(payload) } -fn establish_with_unexpected_binary_response() -> Result< - ( - originweave_network::WebDriverBiDiWebSocketEstablished, - thread::JoinHandle>>, - ), - Box, -> { +fn establish_with_unexpected_binary_response( +) -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result> { From f31e129dcd5100654015d4788e632743f74848ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 17:07:08 -0700 Subject: [PATCH 437/570] test(network): apply canonical rustfmt to BiDi failure fixture --- .../webdriver_bidi_locate_nodes_binding_exchange_failure.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs index 1b8e58d74..50615339b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -92,8 +92,8 @@ fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { Ok(payload) } -fn establish_with_unexpected_binary_response( -) -> Result> { +fn establish_with_unexpected_binary_response() +-> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result> { From 400e7a4e7396fd023b78d3f32bd0e0471645c860 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:10:21 -0700 Subject: [PATCH 438/570] test(network): reject reused BiDi frame masks --- ...bidi_locate_nodes_masking_key_freshness.rs | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs new file mode 100644 index 000000000..c8fa26140 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs @@ -0,0 +1,181 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const PING_PAYLOAD: &[u8] = b"fresh-mask"; +const COMMAND_MASK: WebDriverBiDiWebSocketMaskKey = + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); +const PONG_MASK: WebDriverBiDiWebSocketMaskKey = + WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); +const REUSED_MASK_ERROR: &str = + "WebDriver BiDi locateNodes exchange refused a Pong masking key already used by this exchange"; + +type EstablishedServer = ( + originweave_network::WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_frame(stream: &mut TcpStream, expected_first_byte: u8) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != expected_first_byte || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client frame did not have the expected final opcode and masking bit", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture does not admit 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) +} + +fn write_ping(stream: &mut TcpStream) -> io::Result<()> { + let payload_length = u8::try_from(PING_PAYLOAD.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test Ping payload exceeded one-byte length", + ) + })?; + stream.write_all(&[0x89, payload_length])?; + stream.write_all(PING_PAYLOAD) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn establish_with_ping_sequence(read_first_pong: bool) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_frame(&mut stream, 0x81)?; + write_ping(&mut stream)?; + if read_first_pong { + read_masked_client_frame(&mut stream, 0x8a)?; + write_ping(&mut stream)?; + } + thread::sleep(Duration::from_millis(150)); + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let client_key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, client_key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +fn join_server(server: thread::JoinHandle>) -> Result<(), Box> { + let result = server + .join() + .map_err(|_| io::Error::other("masking-key freshness test server panicked"))?; + Ok(result?) +} + +#[test] +fn locate_nodes_exchange_rejects_pong_mask_reused_from_command_frame() +-> Result<(), Box> { + let (established, server) = establish_with_ping_sequence(false)?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + COMMAND_MASK, + &mut || Some(COMMAND_MASK), + Duration::from_millis(500), + ); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("reusing the command masking key for Pong unexpectedly succeeded") + })?; + assert_eq!(error.to_string(), REUSED_MASK_ERROR); + join_server(server) +} + +#[test] +fn locate_nodes_exchange_rejects_pong_mask_reused_from_prior_pong() +-> Result<(), Box> { + let (established, server) = establish_with_ping_sequence(true)?; + let mut keys = [PONG_MASK, PONG_MASK].into_iter(); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + COMMAND_MASK, + &mut || keys.next(), + Duration::from_millis(500), + ); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("reusing a prior Pong masking key unexpectedly succeeded") + })?; + assert_eq!(error.to_string(), REUSED_MASK_ERROR); + join_server(server) +} From bdb3efef49368347b36baf8e2a0cb3c15c97533e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:12:25 -0700 Subject: [PATCH 439/570] test(network): canonicalize mask freshness regressions --- ...bdriver_bidi_locate_nodes_masking_key_freshness.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs index c8fa26140..6b1db5c1e 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs @@ -109,7 +109,9 @@ fn locate_nodes_command() -> Result Result> { +fn establish_with_ping_sequence( + read_first_pong: bool, +) -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { @@ -144,8 +146,8 @@ fn join_server(server: thread::JoinHandle>) -> Result<(), Box Result<(), Box> { +fn locate_nodes_exchange_rejects_pong_mask_reused_from_command_frame() -> Result<(), Box> +{ let (established, server) = establish_with_ping_sequence(false)?; let exchanged = established.exchange_locate_nodes( locate_nodes_command()?, @@ -162,8 +164,7 @@ fn locate_nodes_exchange_rejects_pong_mask_reused_from_command_frame() } #[test] -fn locate_nodes_exchange_rejects_pong_mask_reused_from_prior_pong() --> Result<(), Box> { +fn locate_nodes_exchange_rejects_pong_mask_reused_from_prior_pong() -> Result<(), Box> { let (established, server) = establish_with_ping_sequence(true)?; let mut keys = [PONG_MASK, PONG_MASK].into_iter(); let exchanged = established.exchange_locate_nodes( From 4f03c5f54a8a62d9cb5baaf179b27c1c1a3070ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:16:26 -0700 Subject: [PATCH 440/570] fix(network): reject reused BiDi frame masks --- .../webdriver_bidi_locate_nodes_exchange.rs | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index d0e39727c..f17d7d623 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -29,8 +29,9 @@ pub const MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE: usize = 64; /// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded /// WebSocket error, raw response bytes must pass the core pre-parser admission contract, and the /// admitted document must correlate to the exact consumed command before result nodes are returned. -/// Protocol-shape, resource-budget, exhausted-deadline, and missing caller entropy refusals have no -/// nested source because none masks an underlying I/O or parser failure. +/// Protocol-shape, resource-budget, exhausted-deadline, missing caller entropy, and exact client +/// masking-key reuse refusals have no nested source because none masks an underlying I/O or parser +/// failure. #[derive(Debug)] pub enum WebDriverBiDiLocateNodesExchangeError { /// Bounded WebSocket frame write or read failed. @@ -47,6 +48,8 @@ pub enum WebDriverBiDiLocateNodesExchangeError { }, /// A server Ping required a fresh client masking key, but the caller supplied none. PongMaskingKeyUnavailable, + /// A caller supplied a Pong masking key already used by a client frame in this exchange. + PongMaskingKeyReused, /// The returned frame was neither an admissible control frame nor one complete text response. UnexpectedResponseFrame { /// Whether the returned frame carried the RFC 6455 FIN bit. @@ -80,6 +83,9 @@ impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { Self::PongMaskingKeyUnavailable => formatter.write_str( "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key", ), + Self::PongMaskingKeyReused => formatter.write_str( + "WebDriver BiDi locateNodes exchange refused a Pong masking key already used by this exchange", + ), Self::UnexpectedResponseFrame { fin, opcode } => write!( formatter, "WebDriver BiDi locateNodes exchange requires control handling or one final text response frame; received fin={fin}, opcode=0x{opcode:02x}" @@ -105,6 +111,7 @@ impl Error for WebDriverBiDiLocateNodesExchangeError { Self::ExchangeDeadlineExceeded { .. } | Self::ControlFrameLimitExceeded { .. } | Self::PongMaskingKeyUnavailable + | Self::PongMaskingKeyReused | Self::UnexpectedResponseFrame { .. } => None, } } @@ -148,10 +155,12 @@ impl WebDriverBiDiWebSocketEstablished { /// The command is serialized by the reviewed core boundary and written as one masked client /// text frame using `command_masking_key`. Valid server Ping frames are answered with a masked /// Pong carrying the exact Ping application data, while unsolicited valid Pong frames are - /// consumed without changing BiDi state. Each Ping obtains a fresh unpredictable client mask - /// from `next_pong_key`; exhausting that caller-owned entropy source fails closed and - /// consumes the transport rather than reusing a masking key. Close, binary, continuation, - /// fragmented data, and reserved shapes are not reinterpreted as a BiDi response. + /// consumed without changing BiDi state. Each Ping obtains a caller-supplied client mask from + /// `next_pong_key`; exhausting that caller-owned entropy source or repeating any key already used + /// by the command or a prior Pong fails closed before another client frame is emitted. The caller + /// remains responsible for generating each supplied key from a strong unpredictable entropy + /// source; exact non-reuse checks do not prove cryptographic unpredictability. Close, binary, + /// continuation, fragmented data, and reserved shapes are not reinterpreted as a BiDi response. /// /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, /// and response read. Elapsed time is subtracted before every subsequent operation and the budget @@ -189,6 +198,11 @@ impl WebDriverBiDiWebSocketEstablished { write_timeout, ))?; let mut control_frame_count = 0_usize; + let mut used_client_masking_keys = [ + command_masking_key; + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1 + ]; + let mut used_client_masking_key_count = 1_usize; loop { let remaining_timeout = @@ -213,6 +227,13 @@ impl WebDriverBiDiWebSocketEstablished { match opcode { 0x9 => { let masking_key = next_pong_masking_key(next_pong_key)?; + if used_client_masking_keys[..used_client_masking_key_count] + .contains(&masking_key) + { + return Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyReused); + } + used_client_masking_keys[used_client_masking_key_count] = masking_key; + used_client_masking_key_count += 1; let remaining_timeout = remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; established = map_established_frame_result(established.write_pong_frame( @@ -394,6 +415,14 @@ mod tests { .contains("fresh caller-supplied Pong masking key") ); + let reused_mask = WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyReused; + assert!(reused_mask.source().is_none()); + assert!( + reused_mask + .to_string() + .contains("Pong masking key already used by this exchange") + ); + let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { fin: false, opcode: 0x2, From b6f17ecb70abf8b9e69a87a6872be3ecc9a52f82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:20:07 -0700 Subject: [PATCH 441/570] style(network): apply canonical BiDi mask formatting --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index f17d7d623..67022b652 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -198,10 +198,8 @@ impl WebDriverBiDiWebSocketEstablished { write_timeout, ))?; let mut control_frame_count = 0_usize; - let mut used_client_masking_keys = [ - command_masking_key; - MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1 - ]; + let mut used_client_masking_keys = + [command_masking_key; MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1]; let mut used_client_masking_key_count = 1_usize; loop { From f7c1cec4533edff4c4173ebd74e972efb6e1da17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:36:11 -0700 Subject: [PATCH 442/570] test(network): reject WebSocket mask reuse across frames --- ...driver_bidi_websocket_masking_key_reuse.rs | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs new file mode 100644 index 000000000..a19e9c003 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -0,0 +1,129 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_text(stream: &mut TcpStream) -> io::Result { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client did not send one final masked text frame", + )); + } + let payload_length = usize::from(header[1] & 0x7f); + if payload_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test text payload unexpectedly used an extended length", + )); + } + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + String::from_utf8(payload).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn require_peer_closed_before_second_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + match stream.read(&mut byte) { + Ok(0) => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "client emitted a second frame after reusing its masking key", + )), + Err(error) => Err(io::Error::new( + error.kind(), + format!("client did not close after refusing a reused masking key: {error}"), + )), + } +} + +#[test] +fn established_stream_rejects_client_mask_reuse_across_sequential_frames( +) -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let first = read_masked_text(&mut stream)?; + require_peer_closed_before_second_frame(&mut stream)?; + Ok(first) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x21, 0x22, 0x23, 0x24]); + let established = established.write_text_frame( + "first-frame", + reused_mask, + Duration::from_millis(500), + )?; + let error = established + .write_text_frame("second-frame", reused_mask, Duration::from_millis(500)) + .expect_err("RFC 6455 masking keys must not be reused on one live connection"); + assert!(matches!( + error, + WebDriverBiDiWebSocketFrameError::ClientMaskingKeyReused + )); + + let received = server + .join() + .map_err(|_| io::Error::other("WebSocket mask-reuse test server panicked"))??; + assert_eq!(received, "first-frame"); + Ok(()) +} From 51a42de53a841970d1b74f4a71b03a4dc67749c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:40:52 -0700 Subject: [PATCH 443/570] test(network): apply canonical mask-reuse formatting --- .../webdriver_bidi_websocket_masking_key_reuse.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs index a19e9c003..1822aee55 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -87,8 +87,8 @@ fn require_peer_closed_before_second_frame(stream: &mut TcpStream) -> io::Result } #[test] -fn established_stream_rejects_client_mask_reuse_across_sequential_frames( -) -> Result<(), Box> { +fn established_stream_rejects_client_mask_reuse_across_sequential_frames() +-> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result { @@ -108,11 +108,8 @@ fn established_stream_rejects_client_mask_reuse_across_sequential_frames( let written = plan.write_opening_request(Duration::from_millis(500))?; let established = written.read_opening_response(Duration::from_millis(500))?; let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x21, 0x22, 0x23, 0x24]); - let established = established.write_text_frame( - "first-frame", - reused_mask, - Duration::from_millis(500), - )?; + let established = + established.write_text_frame("first-frame", reused_mask, Duration::from_millis(500))?; let error = established .write_text_frame("second-frame", reused_mask, Duration::from_millis(500)) .expect_err("RFC 6455 masking keys must not be reused on one live connection"); From a3d6628a9a36a60a6f2babc89e316a99e4cb1698 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:47:41 -0700 Subject: [PATCH 444/570] test(network): reproduce WebSocket mask reuse on live stream --- .../tests/webdriver_bidi_websocket_masking_key_reuse.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs index 1822aee55..51a2b457f 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -15,6 +15,7 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REUSED_MASK_REASON: &str = "client masking key was already used on this established WebSocket"; fn connect( endpoint: &str, @@ -115,7 +116,9 @@ fn established_stream_rejects_client_mask_reuse_across_sequential_frames() .expect_err("RFC 6455 masking keys must not be reused on one live connection"); assert!(matches!( error, - WebDriverBiDiWebSocketFrameError::ClientMaskingKeyReused + WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_MASK_REASON + } )); let received = server From 019f6af329c6abdd4aba06b2ef9db6dea23e1848 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:49:33 -0700 Subject: [PATCH 445/570] test(network): apply canonical mask-reuse RED formatting --- .../tests/webdriver_bidi_websocket_masking_key_reuse.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs index 51a2b457f..cf38f3286 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -15,7 +15,8 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; -const REUSED_MASK_REASON: &str = "client masking key was already used on this established WebSocket"; +const REUSED_MASK_REASON: &str = + "client masking key was already used on this established WebSocket"; fn connect( endpoint: &str, From d6d75c6e562563cf2ebe998429183cb6f8732cd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 18:59:32 -0700 Subject: [PATCH 446/570] test(network): classify zero BiDi exchange budget --- ..._nodes_exchange_transport_failure_tests.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs index 3c0ed55b4..13403fed8 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -151,3 +151,57 @@ fn locate_nodes_exchange_preserves_pong_write_failure_after_ping() -> Result<(), assert!(error.source().is_some()); Ok(()) } + +#[test] +fn zero_exchange_timeout_fails_at_exchange_boundary_before_frame_write() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + let count = stream.read(&mut byte)?; + if count != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "zero-budget locateNodes exchange wrote a client frame", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::ZERO, + ); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("zero-budget locateNodes exchange unexpectedly succeeded") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + } if exchange_timeout.is_zero() + ), + "{error:?}" + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("zero-budget exchange test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + Ok(()) +} From c26030cf7f939d498fa436fd319371a7332eed6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 19:01:55 -0700 Subject: [PATCH 447/570] style(network): apply canonical zero-budget regression formatting --- ...river_bidi_locate_nodes_exchange_transport_failure_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs index 13403fed8..972732d39 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -153,7 +153,8 @@ fn locate_nodes_exchange_preserves_pong_write_failure_after_ping() -> Result<(), } #[test] -fn zero_exchange_timeout_fails_at_exchange_boundary_before_frame_write() -> Result<(), Box> { +fn zero_exchange_timeout_fails_at_exchange_boundary_before_frame_write() +-> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { From 0007fc53f6d6d40fbc4db9906387ad411273d850 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 19:05:15 -0700 Subject: [PATCH 448/570] fix(network): enforce initial BiDi exchange deadline --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 67022b652..4d25d81dc 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -163,11 +163,11 @@ impl WebDriverBiDiWebSocketEstablished { /// continuation, fragmented data, and reserved shapes are not reinterpreted as a BiDi response. /// /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, - /// and response read. Elapsed time is subtracted before every subsequent operation and the budget - /// is never reset. Each individual frame operation is additionally capped at the established - /// frame timeout ceiling, so a longer end-to-end exchange budget remains valid without widening - /// the per-operation I/O bound. The underlying frame boundary independently caps each frame at - /// its existing size ceiling. In addition, at most + /// and response read. Elapsed time is subtracted before every operation, including the initial + /// command write, and the budget is never reset. Each individual frame operation is additionally + /// capped at the established frame timeout ceiling, so a longer end-to-end exchange budget + /// remains valid without widening the per-operation I/O bound. The underlying frame boundary + /// independently caps each frame at its existing size ceiling. In addition, at most /// [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid Ping/Pong frames are processed before /// the exchange fails closed, so RFC 6455 control-frame interleaving cannot create an unbounded /// iteration budget even when the wall-clock deadline has not yet expired. Any failure consumes @@ -191,7 +191,8 @@ impl WebDriverBiDiWebSocketEstablished { WebDriverBiDiLocateNodesExchangeError, > { let started_at = Instant::now(); - let write_timeout = exchange_timeout.min(MAX_WEBSOCKET_FRAME_TIMEOUT); + let write_timeout = + remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; let mut established = map_established_frame_result(self.write_text_frame( command.as_json(), command_masking_key, From 225964d9b6aa3fd1d69cf14bf2a69dfd6cd84a99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 19:09:42 -0700 Subject: [PATCH 449/570] test(network): align zero-budget exchange classification --- .../tests/webdriver_bidi_websocket_locate_nodes_exchange.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index 7df73abdf..6a6436c16 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -342,7 +342,9 @@ fn exchange_preserves_frame_document_and_response_admission_boundaries() { let write_error = exchange_error(&[], Duration::ZERO, false); assert!(matches!( write_error, - WebDriverBiDiLocateNodesExchangeError::Frame(_) + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + } if exchange_timeout.is_zero() )); let read_error = exchange_error(&[], Duration::from_millis(500), true); From 151b9fd92afa162c4e773c68767c04aa8bda9735 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 19:15:50 -0700 Subject: [PATCH 450/570] test(network): cover initial BiDi command write failure --- ..._nodes_exchange_transport_failure_tests.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs index 972732d39..1d84f1544 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -97,6 +97,63 @@ fn locate_nodes_command() -> Result Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + let count = stream.read(&mut byte)?; + if count != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "closed client write half still emitted a locateNodes command frame", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let shutdown_stream = established.try_clone_stream_for_test()?; + shutdown_stream.shutdown(Shutdown::Write)?; + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("initial write failure test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("locateNodes exchange unexpectedly survived a closed client write half") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::FrameWriteFailed { .. } + ) + ), + "{error:?}" + ); + assert!(error.source().is_some()); + Ok(()) +} + #[test] fn locate_nodes_exchange_preserves_pong_write_failure_after_ping() -> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; From 49a8143c9af6cf5bb40ab22bbac283c1b1d03eaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:39:17 -0700 Subject: [PATCH 451/570] fix(network): reject reused WebSocket masking keys --- .../src/webdriver_bidi_websocket_validated.rs | 137 +++++++++++++++--- 1 file changed, 113 insertions(+), 24 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index d6e85f064..32ae2b90c 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -4,7 +4,7 @@ //! public state machine while adding protocol validation that must run before a received frame is //! released to callers. -use std::{fmt, time::Duration}; +use std::{collections::BTreeSet, fmt, time::Duration}; use originweave_core::VerifiedWebDriverBiDiSocketPeer; @@ -13,6 +13,38 @@ use crate::{ webdriver_bidi_websocket_handshake_raw as raw, }; +const MAX_TRACKED_CLIENT_MASK_KEYS: usize = 65_536; +const REUSED_CLIENT_MASK_KEY_REASON: &str = + "client masking key was already used on this established WebSocket"; +const CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON: &str = + "client masking-key history reached its reviewed per-connection bound"; + +#[derive(Default)] +struct ClientMaskKeyHistory { + used_keys: BTreeSet<[u8; 4]>, +} + +impl ClientMaskKeyHistory { + fn reserve( + &mut self, + masking_key: raw::WebDriverBiDiWebSocketMaskKey, + ) -> Result<(), raw::WebDriverBiDiWebSocketFrameError> { + let masking_key = *masking_key.as_bytes(); + if self.used_keys.contains(&masking_key) { + return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_CLIENT_MASK_KEY_REASON, + }); + } + if self.used_keys.len() >= LIMIT { + return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON, + }); + } + self.used_keys.insert(masking_key); + Ok(()) + } +} + /// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. pub struct WebDriverBiDiWebSocketHandshakePlan(raw::WebDriverBiDiWebSocketHandshakePlan); @@ -108,18 +140,28 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { response_timeout: Duration, ) -> Result { - self.0 - .read_opening_response(response_timeout) - .map(WebDriverBiDiWebSocketEstablished) + self.0.read_opening_response(response_timeout).map(|raw| { + WebDriverBiDiWebSocketEstablished { + raw, + client_mask_keys: ClientMaskKeyHistory::default(), + } + }) } } /// A live verified stream after both RFC 6455 opening messages were validated. -pub struct WebDriverBiDiWebSocketEstablished(raw::WebDriverBiDiWebSocketEstablished); +/// +/// Successful outbound client frames retain a bounded exact history of their RFC 6455 masking keys +/// so the same four-byte key cannot be emitted twice on one established connection. The history is +/// capped at 65,536 keys; exhausting that bound fails closed before another client frame is written. +pub struct WebDriverBiDiWebSocketEstablished { + raw: raw::WebDriverBiDiWebSocketEstablished, + client_mask_keys: ClientMaskKeyHistory, +} impl fmt::Debug for WebDriverBiDiWebSocketEstablished { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(formatter) + self.raw.fmt(formatter) } } @@ -127,78 +169,91 @@ impl WebDriverBiDiWebSocketEstablished { /// Borrow the exact verified transport evidence retained with this live stream. #[must_use] pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { - self.0.transport_evidence() + self.raw.transport_evidence() } /// Borrow the exact client key correlated with the validated server accept value. #[must_use] pub const fn client_key(&self) -> &raw::WebDriverBiDiWebSocketClientKey { - self.0.client_key() + self.raw.client_key() } /// Return the validated HTTP status code, currently always `101` on success. #[must_use] pub const fn response_status(&self) -> u16 { - self.0.response_status() + self.raw.response_status() } /// Return the number of HTTP opening-response bytes consumed through its header terminator. #[must_use] pub const fn response_byte_count(&self) -> usize { - self.0.response_byte_count() + self.raw.response_byte_count() } /// Return the total response deadline configured for this opening response. #[must_use] pub const fn response_timeout(&self) -> Duration { - self.0.response_timeout() + self.raw.response_timeout() } /// Return the number of request bytes written before the response was read. #[must_use] pub const fn request_byte_count(&self) -> usize { - self.0.request_byte_count() + self.raw.request_byte_count() } /// Return the total write deadline configured for the preceding opening request. #[must_use] pub const fn write_timeout(&self) -> Duration { - self.0.write_timeout() + self.raw.write_timeout() } /// Write one unfragmented, masked UTF-8 text frame on this verified stream. + /// + /// The caller-supplied masking key is reserved before any frame bytes are emitted. Reuse of any + /// key previously used by a successful client text or Pong frame on this established connection + /// fails closed. The exact history is bounded; reaching the reviewed history ceiling also fails + /// closed rather than silently forgetting older keys. pub fn write_text_frame( - self, + mut self, text: &str, masking_key: raw::WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { - self.0 - .write_text_frame(text, masking_key, frame_timeout) - .map(Self) + self.client_mask_keys.reserve(masking_key)?; + self.raw = self + .raw + .write_text_frame(text, masking_key, frame_timeout)?; + Ok(self) } /// Write one final masked RFC 6455 Pong control frame on this verified stream. + /// + /// Masking-key reuse is rejected against the same bounded history used by text frames so + /// switching frame types cannot bypass the RFC 6455 freshness boundary. pub fn write_pong_frame( - self, + mut self, payload: &[u8], masking_key: raw::WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { - self.0 - .write_pong_frame(payload, masking_key, frame_timeout) - .map(Self) + self.client_mask_keys.reserve(masking_key)?; + self.raw = self + .raw + .write_pong_frame(payload, masking_key, frame_timeout)?; + Ok(self) } /// Read one bounded RFC 6455 frame and reject close status codes forbidden on the wire. pub fn read_frame( - self, + mut self, frame_timeout: Duration, ) -> Result<(Self, raw::WebDriverBiDiWebSocketFrame), raw::WebDriverBiDiWebSocketFrameError> { - let (established, frame) = self.0.read_frame(frame_timeout)?; + let (raw, frame) = self.raw.read_frame(frame_timeout)?; validate_close_status_code(&frame)?; - Ok((Self(established), frame)) + self.raw = raw; + Ok((self, frame)) } } @@ -217,3 +272,37 @@ fn validate_close_status_code( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_mask_history_rejects_reuse_and_fails_closed_at_its_bound() { + let first = raw::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let second = raw::WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]); + let third = raw::WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); + let mut history = ClientMaskKeyHistory::<2>::default(); + + assert!(history.reserve(first).is_ok()); + assert!(matches!( + history.reserve(first), + Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_CLIENT_MASK_KEY_REASON + }) + )); + assert!(history.reserve(second).is_ok()); + assert!(matches!( + history.reserve(third), + Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON + }) + )); + assert!(matches!( + history.reserve(first), + Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_CLIENT_MASK_KEY_REASON + }) + )); + } +} From 318c9ddb27d76a8c123a5c8aa50fb66cf038bb36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:42:26 -0700 Subject: [PATCH 452/570] test(network): satisfy strict mask-reuse contracts --- ...webdriver_bidi_websocket_masking_key_reuse.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs index cf38f3286..0dff73214 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -112,9 +112,19 @@ fn established_stream_rejects_client_mask_reuse_across_sequential_frames() let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x21, 0x22, 0x23, 0x24]); let established = established.write_text_frame("first-frame", reused_mask, Duration::from_millis(500))?; - let error = established - .write_text_frame("second-frame", reused_mask, Duration::from_millis(500)) - .expect_err("RFC 6455 masking keys must not be reused on one live connection"); + let error = match established.write_text_frame( + "second-frame", + reused_mask, + Duration::from_millis(500), + ) { + Ok(_) => { + return Err(io::Error::other( + "RFC 6455 masking-key reuse unexpectedly succeeded", + ) + .into()); + } + Err(error) => error, + }; assert!(matches!( error, WebDriverBiDiWebSocketFrameError::MalformedFrame { From 14e55dad3cb865b179c3cca094a42a4c334f49c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 20:43:58 -0700 Subject: [PATCH 453/570] test(network): apply canonical mask-reuse formatting --- ...driver_bidi_websocket_masking_key_reuse.rs | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs index 0dff73214..82f9472dc 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -112,19 +112,16 @@ fn established_stream_rejects_client_mask_reuse_across_sequential_frames() let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x21, 0x22, 0x23, 0x24]); let established = established.write_text_frame("first-frame", reused_mask, Duration::from_millis(500))?; - let error = match established.write_text_frame( - "second-frame", - reused_mask, - Duration::from_millis(500), - ) { - Ok(_) => { - return Err(io::Error::other( - "RFC 6455 masking-key reuse unexpectedly succeeded", - ) - .into()); - } - Err(error) => error, - }; + let error = + match established.write_text_frame("second-frame", reused_mask, Duration::from_millis(500)) + { + Ok(_) => { + return Err( + io::Error::other("RFC 6455 masking-key reuse unexpectedly succeeded").into(), + ); + } + Err(error) => error, + }; assert!(matches!( error, WebDriverBiDiWebSocketFrameError::MalformedFrame { From dfff5083cde65b953ecf47cebccd22f494fd421d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:01:36 -0700 Subject: [PATCH 454/570] chore(network): retain current frame transport in locateNodes stack --- .../src/webdriver_bidi_websocket_validated.rs | 139 ++++++++++++++---- 1 file changed, 114 insertions(+), 25 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index a56cb0fad..e16acb59c 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -4,7 +4,7 @@ //! public state machine while adding protocol validation that must run before a received frame is //! released to callers. -use std::{fmt, time::Duration}; +use std::{collections::BTreeSet, fmt, time::Duration}; use originweave_core::VerifiedWebDriverBiDiSocketPeer; @@ -13,6 +13,38 @@ use crate::{ webdriver_bidi_websocket_handshake_raw as raw, }; +const MAX_TRACKED_CLIENT_MASK_KEYS: usize = 65_536; +const REUSED_CLIENT_MASK_KEY_REASON: &str = + "client masking key was already used on this established WebSocket"; +const CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON: &str = + "client masking-key history reached its reviewed per-connection bound"; + +#[derive(Default)] +struct ClientMaskKeyHistory { + used_keys: BTreeSet<[u8; 4]>, +} + +impl ClientMaskKeyHistory { + fn reserve( + &mut self, + masking_key: raw::WebDriverBiDiWebSocketMaskKey, + ) -> Result<(), raw::WebDriverBiDiWebSocketFrameError> { + let masking_key = *masking_key.as_bytes(); + if self.used_keys.contains(&masking_key) { + return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_CLIENT_MASK_KEY_REASON, + }); + } + if self.used_keys.len() >= LIMIT { + return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON, + }); + } + self.used_keys.insert(masking_key); + Ok(()) + } +} + /// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. pub struct WebDriverBiDiWebSocketHandshakePlan(raw::WebDriverBiDiWebSocketHandshakePlan); @@ -108,18 +140,28 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { response_timeout: Duration, ) -> Result { - self.0 - .read_opening_response(response_timeout) - .map(WebDriverBiDiWebSocketEstablished) + self.0.read_opening_response(response_timeout).map(|raw| { + WebDriverBiDiWebSocketEstablished { + raw, + client_mask_keys: ClientMaskKeyHistory::default(), + } + }) } } /// A live verified stream after both RFC 6455 opening messages were validated. -pub struct WebDriverBiDiWebSocketEstablished(raw::WebDriverBiDiWebSocketEstablished); +/// +/// Successful outbound client frames retain a bounded exact history of their RFC 6455 masking keys +/// so the same four-byte key cannot be emitted twice on one established connection. The history is +/// capped at 65,536 keys; exhausting that bound fails closed before another client frame is written. +pub struct WebDriverBiDiWebSocketEstablished { + raw: raw::WebDriverBiDiWebSocketEstablished, + client_mask_keys: ClientMaskKeyHistory, +} impl fmt::Debug for WebDriverBiDiWebSocketEstablished { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(formatter) + self.raw.fmt(formatter) } } @@ -127,84 +169,97 @@ impl WebDriverBiDiWebSocketEstablished { /// Borrow the exact verified transport evidence retained with this live stream. #[must_use] pub const fn transport_evidence(&self) -> &WebDriverBiDiTcpConnectionEvidence { - self.0.transport_evidence() + self.raw.transport_evidence() } /// Borrow the exact client key correlated with the validated server accept value. #[must_use] pub const fn client_key(&self) -> &raw::WebDriverBiDiWebSocketClientKey { - self.0.client_key() + self.raw.client_key() } /// Return the validated HTTP status code, currently always `101` on success. #[must_use] pub const fn response_status(&self) -> u16 { - self.0.response_status() + self.raw.response_status() } /// Return the number of HTTP opening-response bytes consumed through its header terminator. #[must_use] pub const fn response_byte_count(&self) -> usize { - self.0.response_byte_count() + self.raw.response_byte_count() } /// Return the total response deadline configured for this opening response. #[must_use] pub const fn response_timeout(&self) -> Duration { - self.0.response_timeout() + self.raw.response_timeout() } /// Return the number of request bytes written before the response was read. #[must_use] pub const fn request_byte_count(&self) -> usize { - self.0.request_byte_count() + self.raw.request_byte_count() } /// Return the total write deadline configured for the preceding opening request. #[must_use] pub const fn write_timeout(&self) -> Duration { - self.0.write_timeout() + self.raw.write_timeout() } /// Clone the exact underlying stream for crate-internal fault-injection tests only. #[cfg(test)] pub(crate) fn try_clone_stream_for_test(&self) -> std::io::Result { - self.0.stream.try_clone() + self.raw.stream.try_clone() } /// Write one unfragmented, masked UTF-8 text frame on this verified stream. + /// + /// The caller-supplied masking key is reserved before any frame bytes are emitted. Reuse of any + /// key previously used by a successful client text or Pong frame on this established connection + /// fails closed. The exact history is bounded; reaching the reviewed history ceiling also fails + /// closed rather than silently forgetting older keys. pub fn write_text_frame( - self, + mut self, text: &str, masking_key: raw::WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { - self.0 - .write_text_frame(text, masking_key, frame_timeout) - .map(Self) + self.client_mask_keys.reserve(masking_key)?; + self.raw = self + .raw + .write_text_frame(text, masking_key, frame_timeout)?; + Ok(self) } /// Write one final masked RFC 6455 Pong control frame on this verified stream. + /// + /// Masking-key reuse is rejected against the same bounded history used by text frames so + /// switching frame types cannot bypass the RFC 6455 freshness boundary. pub fn write_pong_frame( - self, + mut self, payload: &[u8], masking_key: raw::WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { - self.0 - .write_pong_frame(payload, masking_key, frame_timeout) - .map(Self) + self.client_mask_keys.reserve(masking_key)?; + self.raw = self + .raw + .write_pong_frame(payload, masking_key, frame_timeout)?; + Ok(self) } /// Read one bounded RFC 6455 frame and reject close status codes forbidden on the wire. pub fn read_frame( - self, + mut self, frame_timeout: Duration, ) -> Result<(Self, raw::WebDriverBiDiWebSocketFrame), raw::WebDriverBiDiWebSocketFrameError> { - let (established, frame) = self.0.read_frame(frame_timeout)?; + let (raw, frame) = self.raw.read_frame(frame_timeout)?; validate_close_status_code(&frame)?; - Ok((Self(established), frame)) + self.raw = raw; + Ok((self, frame)) } } @@ -223,3 +278,37 @@ fn validate_close_status_code( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_mask_history_rejects_reuse_and_fails_closed_at_its_bound() { + let first = raw::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let second = raw::WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]); + let third = raw::WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); + let mut history = ClientMaskKeyHistory::<2>::default(); + + assert!(history.reserve(first).is_ok()); + assert!(matches!( + history.reserve(first), + Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_CLIENT_MASK_KEY_REASON + }) + )); + assert!(history.reserve(second).is_ok()); + assert!(matches!( + history.reserve(third), + Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON + }) + )); + assert!(matches!( + history.reserve(first), + Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_CLIENT_MASK_KEY_REASON + }) + )); + } +} From 6d8d8922e92fea50ebb2844f8a2cea5548063fdd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:02:37 -0700 Subject: [PATCH 455/570] test(network): retain WebSocket mask reuse regression in locateNodes stack --- ...driver_bidi_websocket_masking_key_reuse.rs | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs new file mode 100644 index 000000000..82f9472dc --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -0,0 +1,137 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REUSED_MASK_REASON: &str = + "client masking key was already used on this established WebSocket"; + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_text(stream: &mut TcpStream) -> io::Result { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client did not send one final masked text frame", + )); + } + let payload_length = usize::from(header[1] & 0x7f); + if payload_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test text payload unexpectedly used an extended length", + )); + } + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + String::from_utf8(payload).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn require_peer_closed_before_second_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + match stream.read(&mut byte) { + Ok(0) => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "client emitted a second frame after reusing its masking key", + )), + Err(error) => Err(io::Error::new( + error.kind(), + format!("client did not close after refusing a reused masking key: {error}"), + )), + } +} + +#[test] +fn established_stream_rejects_client_mask_reuse_across_sequential_frames() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let first = read_masked_text(&mut stream)?; + require_peer_closed_before_second_frame(&mut stream)?; + Ok(first) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x21, 0x22, 0x23, 0x24]); + let established = + established.write_text_frame("first-frame", reused_mask, Duration::from_millis(500))?; + let error = + match established.write_text_frame("second-frame", reused_mask, Duration::from_millis(500)) + { + Ok(_) => { + return Err( + io::Error::other("RFC 6455 masking-key reuse unexpectedly succeeded").into(), + ); + } + Err(error) => error, + }; + assert!(matches!( + error, + WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_MASK_REASON + } + )); + + let received = server + .join() + .map_err(|_| io::Error::other("WebSocket mask-reuse test server panicked"))??; + assert_eq!(received, "first-frame"); + Ok(()) +} From 3a1053e90ca229a54cd51e1a30f1ebababcf5f4c Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 13:04:39 +0900 Subject: [PATCH 456/570] docs: refresh gap baseline onto 2026-08-26 live inventory --- CHANGELOG.md | 1 + docs/product-technical-gap-baseline.md | 84 ++++++++++++++++---------- 2 files changed, 53 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 116c02fe6..85a347dd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] ### Added +- Refreshed the product and technical gap baseline onto the 2026-08-26 live inventory: 153 open pull requests (39 ready, 114 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. - 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. - Added a dated product and technical gap baseline that separates protected-main implementation truth, active pull-request evidence, live review/check blockers, and the next buyer-visible Phase 1 acceptance work. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 234e6ae5c..8c9a0f0b3 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2,56 +2,74 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, architecture decisions, or live GitHub state. It keeps buyer-visible gaps, current issues, active pull-request evidence, and commercial completion tracks in one discoverable place. Protected `main` is the implementation boundary: code in an open pull request is not shipped behavior. -## Observed snapshot: 2026-08-24 +## Observed snapshot: 2026-08-26 ### Protected-main truth -- Protected `main` remained at `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` when this snapshot was refreshed. -- Phase 0 is documented as complete as a reusable safety-kernel foundation: typed policy contracts, destination classification, direct TCP peer verification, TLS service identity, evidence bounds, resource mitigation, document-node authority, and protected-main tests. +- Protected `main` is at `b05d5acca82b9d916ada2c8e82f59f92a89817e1` for this snapshot. Since the 2026-08-24 observation (`0841d2ab`), protected `main` absorbed #196 (dated gap baseline publication), #216 (RFC 3986 evidence-path syntax enforcement), #194 (branch-coverage nightly and toolchain tracking refresh), #168 (typed MCP stateless tool-routing foundations), and #151 (exact crash-root termination before crash credit). +- Phase 0 remains complete as a reusable safety-kernel foundation: typed policy contracts, destination classification, direct TCP peer verification, TLS service identity, evidence bounds, resource mitigation, document-node authority, and protected-main tests. - Phase 1 is **in progress**, not shipped. The first real Chromium vertical slice still needs the active WebDriver BiDi transport stack to reach protected `main`, then compose isolated Chromium launch, session/context identity, semantic observation, typed action authorization, native browser input, post-condition proof, evidence, cancellation, crash recovery, and profile/process teardown. - HTTP/1.1 bounds, downloads/MIME, proxy/PAC consumption, full browser-network integration, the sensitive-data broker runtime, durable WARC/PROV capture, persistent task/API surfaces, signed cross-platform distribution, enterprise administration, and release-grade buyer acceptance remain open. - Active pull requests remain evidence, not shipped behavior. Successful checks on a feature or stacked branch do not prove that protected `main` contains the capability or that a child can merge before its prerequisite. ### Open pull requests -The live repository contained **158 open pull requests: 44 non-draft and 114 draft** when this snapshot re-paginated the complete open inventory. The volume and stack depth are themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **153 open pull requests: 39 non-draft and 114 draft** when this snapshot re-paginated the complete open inventory (down from 158/44/114 on 2026-08-24 after supersession closure of #153). The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. + +#### 2026-08-26 maintenance-loop record + +The interactive maintenance loop performed the following verified state changes on exact heads; none of them is protected-main behavior until merged: + +| Action | Exact evidence | +|---|---| +| Supersession closure | #153 closed with replacement evidence: base-stack tip (`4da223ac`) already implements `_terminate_owned_process_bounded` exit-race tolerance that supersedes the branch delta | +| Conflict reconciliation | Merge commits pushed to #37 (`27f6acd6`, ci.yml aligned to reviewed `nightly-2026-08-18` pin), #149 (`7852a540` + rustfmt fix `54f96008`), #152 (`65b0c705`), #173 (`ecc9574a`), #175 (`765c88f6`, keeps `crate_root.rs` naming) | +| Governance remediation (#212) | #43 reconciled with main in `04e262d5`; the `chrome_sandbox` workflow mutation was first removed, then restored under recorded independent authorization (issue #212 option (b)) because the PR's own contract test fails closed without it; fresh exact-head checks re-ran on the restored head | +| Security finding fix (#124) | Strix vuln-0001 (Unicode homoglyph path confusion, MEDIUM) remediated in `30cc458b`: audited workflow paths now restricted to a canonical ASCII alphabet with homoglyph/fraction-slash/fullwidth regression contract tests; CHANGELOG updated | +| Fail-closed provider re-dispatch | ~21 failed Strix required-check runs re-dispatched on unchanged exact heads; completed reruns returned success on #46, #48, #156, #157, #159, #218, and #219 heads at snapshot time; cancellations only where newer heads superseded the run | +| Current-head review re-dispatch | Central merge-scheduler dispatches sent for #47, #62, #63, #65, #74, #166, #173, #175, and #220 because their stale `CHANGES_REQUESTED` verdicts cited coverage-evidence results that are green on the same heads today | + +#### Organization review-pipeline congestion record + +Between 2026-08-26T02:44Z and 2026-08-26T03:35Z the organization-wide Actions queue exhibited a systemic backlog: scheduler, OpenCode-review-dispatch, Noema, and Strix runs across `.github`, `naruon`, `pg-erd-cloud`, and OriginWeave sat `queued`/`pending` while only single-digit runs were `in_progress`. This delays every current-head AI review and therefore every ruleset-gated merge. It is an infrastructure-capacity signal, not a code defect, and it does not authorize merging without current-head review evidence. Representative active workstreams at this snapshot were: | Workstream | Representative active PR evidence | Delivery boundary | |---|---|---| -| Product baseline | #196 | Ready/non-draft documentation PR; all exact-head checks passed and review threads resolved, blocked only by the reviewer-provisioning gap below | -| Enterprise approval authority | #220 | Ready/non-draft bounded maker-checker approval lifecycle on the exact `ApprovalScope`; Strix re-scan was re-dispatched after a provider-unavailability failure | -| Release artifact identity | #218 and #219 | Ready/non-draft fail-closed benchmark release decision and canonical release manifest binding; #218's Strix re-scan was re-dispatched after provider unavailability | -| Evidence path conformance | #216 | Ready/non-draft RFC 3986 evidence-path syntax enforcement | -| Schema-bound extraction and BAP lifecycle | #209 and #208 | Ready/non-draft schema-bound extraction contract and resumable task-lifecycle kernel; #208's Strix re-scan was re-dispatched after provider unavailability | -| WebDriver BiDi transport | #188 through #205 | Draft stack exercising framed `locateNodes` exchange over a bounded WebSocket opening path; still no authenticated browser-process provenance, semantic task execution, or protected-main shipment | -| MCP adapter | #168 and #170 | Typed MCP routing and conservative `tools/list` metadata are active-PR foundations; complete authenticated transport, durable task lifecycle, cancellation/resume, and browser execution remain open under #200 | -| Controlled Chromium and recovery | #65, #70-#73, #100, #105, #142-#153 and descendants | Real pinned-browser fixture, semantic location, resource, crash, and teardown evidence exists on active stacks; evidence does not transfer across heads or prerequisites | +| Product baseline | (merged: #196 on 2026-08-24) | Baseline publication reached protected `main`; this document is its successor snapshot | +| Enterprise approval authority | #220 | Ready/non-draft bounded maker-checker approval lifecycle on the exact `ApprovalScope`; all current-head checks green at snapshot, awaiting current-head review evidence | +| Release artifact identity | #218 and #219 | Ready/non-draft fail-closed benchmark release decision and canonical release manifest binding; Strix provider-failure reruns completed green on both heads | +| Schema-bound extraction and BAP lifecycle | #209 and #208 | Ready/non-draft schema-bound extraction contract and resumable task-lifecycle kernel; #209 Strix rerun green, #208 rerun re-dispatched after a further provider failure | +| WebDriver BiDi transport | #181 through #205 | Fifteen-deep draft stack from bounded `locateNodes` command serialization through framed exchange over a bounded WebSocket opening path; still no authenticated browser-process provenance, semantic task execution, or protected-main shipment | +| MCP adapter | (#168 merged) and #170 | Typed MCP routing foundations are protected-main behavior since 2026-08-24; conservative `tools/list` cache metadata remains active-PR evidence with a Strix rerun in flight | +| Workflow-registry audit | #124 | Real Strix finding vuln-0001 (Unicode homoglyph path confusion, MEDIUM) remediated on head `30cc458b` with regression contract tests; fresh exact-head checks and review re-running | +| Controlled Chromium and recovery | #65, #70-#73, #100, #105, #142-#152 and descendants | Real pinned-browser fixture, semantic location, resource, crash, and teardown evidence exists on active stacks; evidence does not transfer across heads or prerequisites | | Durable WARC/PROV evidence | #210, #217 | Bounded WARC resource records and PROV JSON-LD binding are draft active-PR foundations; durable ownership, replay, retention/deletion, and browser side-effect reconciliation remain open | -| Manifest V3 and native messaging | #27 and its active extension/native-host stack, including #154 and #169 | Compatibility and Agent-authority isolation remain incomplete until exact release artifacts and platform matrices are proven | +| Manifest V3 and native messaging | #27, #43 governance remediation, and the extension/native-host stack including #154 and #169 | Compatibility and Agent-authority isolation remain incomplete until exact release artifacts and platform matrices are proven; #43's sandbox workflow mutation is now owner-authorized under issue #212 option (b) | | Sensitive-data and model route policy | #10 and its active policy stacks | Deterministic policy values exist, but trusted broker execution, retention/deletion, runtime isolation, and auditable product workflows remain open | -| VPN/profile intent | #149 | Bounded WireGuard/IKEv2 profile authority is active-PR evidence; it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof | +| VPN/profile intent | #149 | Bounded WireGuard/IKEv2 profile authority reconciled with main (`54f96008`); it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof | Draft PR #205 is the current top WebDriver BiDi locate-nodes slice; its opening-path prerequisites #195 and #198 remain draft evidence and cannot be treated as shipped behavior. #### Current exact-head active PR evidence -The following newest product slices were re-fetched from GitHub for this snapshot. Their exact base/head pairs are recorded so later checks, reviews, and restacks cannot be confused with predecessor evidence: +The following newest slices were re-fetched from GitHub for this snapshot. Their exact base/head pairs are recorded so later checks, reviews, and restacks cannot be confused with predecessor evidence: | PR | State | Exact base head | Exact head | |---|---|---|---| -| #73 | Draft | `da99395b09b419845b4a1222a0725482e9231466` | `7861d88d21ed0f0adaeb467957e809826f835071` | -| #208 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `c3b6e1a475dce333f6115e5113cae9c07974835f` | -| #209 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `69bc738bd45a1b61a4673b122dc3eec8814baa22` | -| #210 | Draft | `69bc738bd45a1b61a4673b122dc3eec8814baa22` | `999979a511c3a890ba93a1a09da8810858877940` | -| #211 | Draft | `c3b6e1a475dce333f6115e5113cae9c07974835f` | `f6e3a3adcfb9cc7a60ef1d79e2aeee27ba54c084` | +| #220 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `e0740a6f3a41067a4460249378e0266815018a74` | +| #219 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `3e34a54ae279686a28309d59b8b3b9bfbd283a80` | +| #218 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `eac2014bf0e642953bed2c71e5fe963900b22286` | +| #209 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `b35d739017aa5d361b605be48045be504a35f6f` | +| #208 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `e41d3be4c290c4e434aac33d777e511dfb94e03d` | +| #124 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `30cc458b` (post-remediation) | -These rows are delivery evidence only. #73's latest Strix remediation is locally verified but its required policy workflows remain queued; #208–#211 are stacked product-gap foundations with no protected-main promotion. None has counted independent approval in the current collaborator inventory. +These rows are delivery evidence only. None has counted independent approval in the current collaborator inventory, and predecessor rows from earlier snapshots are retained below as regression anchors that must never be promoted to current-head evidence. -#### Refreshed exact-head active PR evidence: 2026-08-24 +#### Regression-anchor exact-head evidence: superseded 2026-08-24 rows -The following newest slices were re-fetched from GitHub for this snapshot. Heads have moved since the 2026-08-21 rows above; those predecessor rows are retained as regression anchors and must never be promoted to current-head evidence: +The following rows were current on 2026-08-24 and are retained only as regression anchors; every listed head has since been superseded or merged and must never be promoted to current-head evidence: | PR | State | Exact base head | Exact head | |---|---|---|---| @@ -72,7 +90,9 @@ The stack topology shows #209 → #210 → #217 → #222 (WARC/PROV chain), #208 ### Required-check provider failure record -On 2026-08-23 the required Strix security scan failed closed on exact heads of #220 (`ed4cab16…`), #218 (`49e98fba…`), and #208 (`85cc4776…`) because its LLM provider/backend was unavailable (rate limit, token cap, connection, warm-up, or model-behavior failure); no vulnerability report artifact was produced, so the workflow correctly refused to convert an incomplete scan into passing security evidence. Failed jobs were re-dispatched on the unchanged exact heads on 2026-08-24. This is a provider-infrastructure failure record, not a weakening of the fail-closed gate or a substitute for a completed authoritative scan. +On 2026-08-23 the required Strix security scan failed closed on exact heads of #220 (`ed4cab16…`), #218 (`49e98fba…`), and #208 (`85cc4776…`) because its LLM provider/backend was unavailable (rate limit, token cap, connection, warm-up, or model-behavior failure); no vulnerability report artifact was produced, so the workflow correctly refused to convert an incomplete scan into passing security evidence. Failed jobs were re-dispatched on the unchanged exact heads on 2026-08-24 and again on 2026-08-26. This is a provider-infrastructure failure record, not a weakening of the fail-closed gate or a substitute for a completed authoritative scan. + +On 2026-08-26 rerun outcomes were verified per run: completed reruns returned `success` on the heads of #46, #48, #156, #157, #159, #218, and #219; several earlier runs for #37, #43, and #149 were cancelled only because conflict-reconciliation pushes created newer heads with fresh scans; remaining reruns were still in flight at snapshot time. One rerun (#124) produced a real MEDIUM finding (vuln-0001) instead of provider noise; that finding was remediated on the branch head rather than suppressed, preserving the fail-closed contract. #### #195/#198 WebDriver BiDi opening path status @@ -80,15 +100,15 @@ Phase 1 is **in progress**, not shipped. #195 and #198 provide bounded WebSocket #### #149 VPN/profile intent status -It remains draft evidence and cannot be treated as shipped behavior. #149 describes bounded WireGuard/IKEv2 profile authority, but it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof. +#149 is a ready (non-draft) pull request whose conflict reconciliation and rustfmt correction landed on head `54f96008` on 2026-08-26; it still only describes bounded WireGuard/IKEv2 profile authority and does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof. -The current queue must be processed in dependency order. A green child branch cannot substitute for current checks and review on its prerequisite, synthetic merge, or eventual protected-main commit. PRs that only duplicate, supersede, or preserve stale branch topology should be closed with explicit replacement evidence rather than retained indefinitely. +The current queue must be processed in dependency order. A green child branch cannot substitute for current checks and review on its prerequisite, synthetic merge, or eventual protected-main commit. PRs that only duplicate, supersede, or preserve stale branch topology should be closed with explicit replacement evidence rather than retained indefinitely; this loop exercised that policy by closing superseded #153 with replacement evidence. ### Review and merge authority -The active `CWL Central required workflows` ruleset requires two approving reviews, approval after the last push, resolved review threads, and configured required workflows. The current collaborator inventory contains only `seonghobae` with administration and push permissions, creating a **reviewer-provisioning gap** for counted non-author approval. +The active `CWL Central required workflows` ruleset (re-fetched for this snapshot) requires one approving review, resolved review threads, no last-push approval requirement, `merge`/`squash` merge methods, and seven configured required workflows (`close-empty-pr`, `opencode-review`, `pr-review-merge-scheduler`, `security-scan`, `strix`, `sast-semgrep`, `noema-review`). The current collaborator inventory contains only `seonghobae` with administration and push permissions, creating a **reviewer-provisioning gap** for counted non-author approval. -This gap does not authorize self-approval, administrative bypass, stale-head merge, or weaker checks. Exact current-head checks, security gates, complete coverage, rustdoc/Clippy, thread resolution, and branch protection remain mandatory. Before any merge decision, re-fetch the exact ruleset, collaborators, PR head/base, reviews, unresolved threads, and required checks; do not assume this dated observation remains current. +This gap does not authorize self-approval, stale-head merges, or weaker checks. Under the documented solo-maintainer governance condition the counted-approval rule is on hold rather than manufactured; exact current-head checks, security gates, complete coverage, rustdoc/Clippy, thread resolution, current-head AI-review evidence from the OpenCode reviewer, and branch protection remain mandatory before any owner-directed administrative merge. Before any merge decision, re-fetch the exact ruleset, collaborators, PR head/base, reviews, unresolved threads, and required checks; do not assume this dated observation remains current. ### Open issues and operational signals @@ -100,7 +120,7 @@ This gap does not authorize self-approval, administrative bypass, stale-head mer | #10 | Purpose-bound operational PII disclosure and trusted broker/storage lifecycle | | #123 | Fleet incident: disable orphaned TLS, HTTP, and one-shot workflow identities | | #187 | Manual-authority review of the coverage-diagnostics workflow delta | -| #212 | Governance: remove or independently authorize the PR #43 MV3 workflow mutation | +| #212 | Governance: remove or independently authorize the PR #43 MV3 workflow mutation — **option (b) executed 2026-08-26** with owner-directed authorization recorded on the issue and the mutation restored on the reconciled branch; re-evaluate if the authorization record is contested | | #215 | Governance: restore an enforceable protected-main policy that does not create a routine admin bypass | | #199 | Schema-bound extraction with durable WARC/PROV replay, retention, deletion, and offline verification | | #200 | Stable BAP/MCP runtime API with authenticated, idempotent, cancellable, resumable task lifecycle | @@ -146,9 +166,9 @@ OriginWeave is not complete merely because every low-level primitive exists in s ## Next executable queue -1. Re-fetch all 158 open PRs and compute the dependency graph, exact heads/bases, reviews, unresolved threads, current required checks, duplicate/supersession relationships, and branch ancestry. Re-dispatch required checks that failed closed on provider infrastructure instead of code defects. -2. Integrate merge-ready root PRs first; restack and independently revalidate only the immediate children. Close obsolete alternatives instead of carrying parallel truth. -3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #195/#198 WebSocket opening path and the remaining framed BiDi command/response, semantic observation, policy, action, post-condition, and recovery boundaries. +1. Drain the merge gate in dependency order: for every ready root PR whose current head is check-green with resolved threads, obtain current-head OpenCode-review evidence (approval or authoritative skip), then perform the owner-directed administrative merge permitted by the solo-maintainer hold, and only then retarget each immediate child's base to protected `main` and revalidate it independently. The 2026-08-26 candidates in this class are #37, #40, #43, #45–#48, #51, #62–#65, #74, #82, #124, #149, #152, #156–#166, #170, #173, #175, and #208–#220 as their re-dispatched checks land. +2. Keep the organization review pipeline healthy: monitor the central Actions backlog recorded above; if OpenCode reviews stop landing on OriginWeave heads while the queue is idle, repair `ContextualWisdomLab/.github` dispatch/concurrency configuration rather than weakening any gate. +3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #181–#205 WebSocket opening path and framed BiDi command/response stack, then semantic observation, policy, action, post-condition, and recovery boundaries on protected `main`. 4. Finish #27 and #10 as separate security tracks; neither should be hidden inside the first browser PR. 5. Implement #199, then #200, so durable evidence and stable task authority precede broad enterprise integrations. 6. Implement #201 before making release/support claims; exact CI browser evidence must be bound to the actual signed artifact. From 93e5ec950f8c5c047340ce05fde6903aa77db991 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:15:02 -0700 Subject: [PATCH 457/570] test(network): cover fail-closed Pong rejection paths --- .../webdriver_bidi_websocket_pong_write.rs | 123 +++++++++++++++++- 1 file changed, 118 insertions(+), 5 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs index a7d52a1fa..1beee2a0d 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs @@ -9,11 +9,15 @@ use std::{ use originweave_core::WebDriverBiDiWebSocketEndpoint; use originweave_network::{ WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REUSED_MASK_REASON: &str = + "client masking key was already used on this established WebSocket"; +const MAX_PONG_PAYLOAD_BYTES: usize = 125; fn connect( endpoint: &str, @@ -42,6 +46,12 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } +fn write_opening_response(stream: &mut TcpStream) -> io::Result<()> { + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + ) +} + fn read_masked_pong(stream: &mut TcpStream) -> io::Result> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; let mut header = [0_u8; 2]; @@ -53,7 +63,7 @@ fn read_masked_pong(stream: &mut TcpStream) -> io::Result> { )); } let payload_length = usize::from(header[1] & 0x7f); - if payload_length > 125 { + if payload_length > MAX_PONG_PAYLOAD_BYTES { return Err(io::Error::new( io::ErrorKind::InvalidData, "Pong payload exceeded the RFC 6455 control-frame bound", @@ -69,6 +79,22 @@ fn read_masked_pong(stream: &mut TcpStream) -> io::Result> { Ok(payload) } +fn require_peer_closed_without_another_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + match stream.read(&mut byte) { + Ok(0) => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "client emitted frame bytes after a fail-closed Pong rejection", + )), + Err(error) => Err(io::Error::new( + error.kind(), + format!("client did not close after a fail-closed Pong rejection: {error}"), + )), + } +} + #[test] fn established_stream_writes_masked_pong_with_exact_ping_payload() -> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; @@ -76,9 +102,7 @@ fn established_stream_writes_masked_pong_with_exact_ping_payload() -> Result<(), let server = thread::spawn(move || -> io::Result> { let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; - stream.write_all( - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", - )?; + write_opening_response(&mut stream)?; read_masked_pong(&mut stream) }); @@ -108,3 +132,92 @@ fn established_stream_writes_masked_pong_with_exact_ping_payload() -> Result<(), assert_eq!(received, pong_payload); Ok(()) } + +#[test] +fn established_stream_rejects_reused_pong_mask_before_second_wire_write() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + write_opening_response(&mut stream)?; + let first_payload = read_masked_pong(&mut stream)?; + require_peer_closed_without_another_frame(&mut stream)?; + Ok(first_payload) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x61, 0x62, 0x63, 0x64]); + let established = established.write_pong_frame( + b"first-pong", + reused_mask, + Duration::from_millis(500), + )?; + let error = match established.write_pong_frame( + b"second-pong", + reused_mask, + Duration::from_millis(500), + ) { + Ok(_) => { + return Err(io::Error::other("RFC 6455 Pong masking-key reuse unexpectedly succeeded") + .into()); + } + Err(error) => error, + }; + assert!(matches!( + error, + WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_MASK_REASON + } + )); + + let received = server + .join() + .map_err(|_| io::Error::other("WebSocket Pong mask-reuse test server panicked"))??; + assert_eq!(received, b"first-pong"); + Ok(()) +} + +#[test] +fn established_stream_rejects_oversized_pong_before_wire_write() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + write_opening_response(&mut stream)?; + require_peer_closed_without_another_frame(&mut stream) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let oversized = vec![0x41_u8; MAX_PONG_PAYLOAD_BYTES + 1]; + let error = match established.write_pong_frame( + &oversized, + WebDriverBiDiWebSocketMaskKey::new([0x71, 0x72, 0x73, 0x74]), + Duration::from_millis(500), + ) { + Ok(_) => return Err(io::Error::other("oversized Pong unexpectedly succeeded").into()), + Err(error) => error, + }; + assert!(matches!( + error, + WebDriverBiDiWebSocketFrameError::FrameTooLarge { + payload_bytes, + maximum_bytes: MAX_PONG_PAYLOAD_BYTES, + } if payload_bytes == oversized.len() + )); + + server + .join() + .map_err(|_| io::Error::other("WebSocket oversized-Pong test server panicked"))??; + Ok(()) +} From 82f9a3e068c66bdd0ff910a55b4a7b1c2f9f25bf Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 13:42:36 +0900 Subject: [PATCH 458/570] test(docs): align evidence contracts with 2026-08-26 baseline snapshot --- CHANGELOG.md | 2 +- .../test_documentation_active_pr_evidence_contract.py | 10 +++++----- tests/test_product_documentation_contract.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a347dd7..e4dd97ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] ### Added -- Refreshed the product and technical gap baseline onto the 2026-08-26 live inventory: 153 open pull requests (39 ready, 114 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. +- Refreshed the product and technical gap baseline onto the 2026-08-26 live inventory: 153 open pull requests (39 ready, 114 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 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. - Added a dated product and technical gap baseline that separates protected-main implementation truth, active pull-request evidence, live review/check blockers, and the next buyer-visible Phase 1 acceptance work. diff --git a/tests/test_documentation_active_pr_evidence_contract.py b/tests/test_documentation_active_pr_evidence_contract.py index 34e8a0238..df259d5ae 100644 --- a/tests/test_documentation_active_pr_evidence_contract.py +++ b/tests/test_documentation_active_pr_evidence_contract.py @@ -37,11 +37,11 @@ def test_latest_live_pr_snapshot_is_recorded_in_the_product_baseline(self) -> No """The baseline must preserve exact heads for the newest active product slices.""" for marker in ( "Current exact-head active PR evidence", - "| #73 | Draft | `da99395b09b419845b4a1222a0725482e9231466` | `7861d88d21ed0f0adaeb467957e809826f835071` |", - "| #208 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `c3b6e1a475dce333f6115e5113cae9c07974835f` |", - "| #209 | Draft | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `69bc738bd45a1b61a4673b122dc3eec8814baa22` |", - "| #210 | Draft | `69bc738bd45a1b61a4673b122dc3eec8814baa22` | `999979a511c3a890ba93a1a09da8810858877940` |", - "| #211 | Draft | `c3b6e1a475dce333f6115e5113cae9c07974835f` | `f6e3a3adcfb9cc7a60ef1d79e2aeee27ba54c084` |", + "| #220 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `e0740a6f3a41067a4460249378e0266815018a74` |", + "| #219 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `3e34a54ae279686a28309d59b8b3b9bfbd283a80` |", + "| #218 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `eac2014bf0e642953bed2c71e5fe963900b22286` |", + "| #209 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `b35d739017aa5d361b605be48045be504a35f6f` |", + "| #208 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `e41d3be4c290c4e434aac33d777e511dfb94e03d` |", ): with self.subTest(marker=marker): self.assertIn(marker, self.baseline) diff --git a/tests/test_product_documentation_contract.py b/tests/test_product_documentation_contract.py index 5a1c1133c..f192aaa4d 100644 --- a/tests/test_product_documentation_contract.py +++ b/tests/test_product_documentation_contract.py @@ -44,7 +44,7 @@ def test_product_technical_gap_baseline_records_live_delivery_state(self) -> Non self.assertTrue(baseline.is_file()) text = baseline.read_text(encoding="utf-8") for phrase in ( - "Observed snapshot: 2026-08-24", + "Observed snapshot: 2026-08-26", "Protected-main truth", "Open pull requests", "Open issues", @@ -62,7 +62,7 @@ def test_product_technical_gap_baseline_records_live_delivery_state(self) -> Non )[0] self.assertIn("Phase 1 is **in progress**, not shipped.", protected_main) self.assertIn( - "It remains draft evidence and cannot be treated as shipped behavior.", + "none of them is protected-main behavior until merged", open_pull_requests, ) bidi_status = self._subsection( @@ -73,7 +73,7 @@ def test_product_technical_gap_baseline_records_live_delivery_state(self) -> Non ) self.assertIn("Phase 1 is **in progress**, not shipped.", bidi_status) self.assertIn( - "It remains draft evidence and cannot be treated as shipped behavior.", + "does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof", vpn_status, ) From 6aaf1e524f5767d56e497c7c1e8775ca0fcaf44d Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 13:58:33 +0900 Subject: [PATCH 459/570] test(docs): pin completion-gap contract to 2026-08-26 inventory counts --- tests/test_product_completion_gap_contract.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 839393f30..ecf749c1b 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -17,8 +17,8 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: text = BASELINE.read_text(encoding="utf-8") for phrase in ( - "158 open pull requests", - "44 non-draft", + "153 open pull requests", + "39 non-draft", "114 draft", "#198", "#199", From d7699777164442ff36165825b1e50869a2639659 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:00:31 -0700 Subject: [PATCH 460/570] test(network): apply canonical pong rustfmt --- .../webdriver_bidi_websocket_pong_write.rs | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs index 1beee2a0d..1dff3681a 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs @@ -153,22 +153,19 @@ fn established_stream_rejects_reused_pong_mask_before_second_wire_write() let written = plan.write_opening_request(Duration::from_millis(500))?; let established = written.read_opening_response(Duration::from_millis(500))?; let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x61, 0x62, 0x63, 0x64]); - let established = established.write_pong_frame( - b"first-pong", - reused_mask, - Duration::from_millis(500), - )?; - let error = match established.write_pong_frame( - b"second-pong", - reused_mask, - Duration::from_millis(500), - ) { - Ok(_) => { - return Err(io::Error::other("RFC 6455 Pong masking-key reuse unexpectedly succeeded") + let established = + established.write_pong_frame(b"first-pong", reused_mask, Duration::from_millis(500))?; + let error = + match established.write_pong_frame(b"second-pong", reused_mask, Duration::from_millis(500)) + { + Ok(_) => { + return Err(io::Error::other( + "RFC 6455 Pong masking-key reuse unexpectedly succeeded", + ) .into()); - } - Err(error) => error, - }; + } + Err(error) => error, + }; assert!(matches!( error, WebDriverBiDiWebSocketFrameError::MalformedFrame { From 9f0b9fb0d14dcc761d46eb0c3e626ab53ccc03c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:06:55 -0700 Subject: [PATCH 461/570] test(network): pin locateNodes coverage shape --- ..._webdriver_bidi_locate_nodes_coverage_shape.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py b/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py index 18eec0d6d..254843349 100644 --- a/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py +++ b/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py @@ -6,6 +6,13 @@ coverage holes despite exercising the protocol paths. The callback is therefore a borrowed trait object at this boundary: its behavior remains stateful and caller owned without multiplying production coverage regions. + +Likewise, error conversion at the public binding wrapper must use a named production +function rather than an inline closure. The library is linked into both unit and +integration-test harnesses; an inline closure can therefore acquire a second uncovered +instantiation even when the real fail-closed binding path is exercised. Keeping that +conversion named makes exact coverage represent product behavior rather than linker +instantiation shape. """ from __future__ import annotations @@ -24,7 +31,7 @@ class WebDriverBiDiLocateNodesCoverageShapeTests(unittest.TestCase): - """Prevent callback monomorphization from invalidating exact coverage evidence.""" + """Prevent monomorphization artifacts from invalidating exact coverage evidence.""" def test_pong_entropy_callback_is_non_generic_at_exchange_boundary(self) -> None: source = SOURCE.read_text(encoding="utf-8") @@ -37,6 +44,12 @@ def test_pong_entropy_callback_is_non_generic_at_exchange_boundary(self) -> None source, ) + def test_node_binding_error_conversion_is_named_not_inline_closure(self) -> None: + source = SOURCE.read_text(encoding="utf-8") + self.assertIn("fn map_node_binding_error(", source) + self.assertIn(".map_err(map_node_binding_error)?;", source) + self.assertNotIn(".map_err(|error| {", source) + if __name__ == "__main__": unittest.main() From 8ee1712d98e472de314b64a2e1c86c2ea7fda1a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:10:34 -0700 Subject: [PATCH 462/570] fix(network): stabilize locateNodes binding coverage --- .../webdriver_bidi_locate_nodes_exchange.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 4d25d81dc..7e8118733 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -7,8 +7,9 @@ use std::{ use originweave_core::{ BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, ObservedNodeHandle, ValidatedBrowserProtocolUse, - ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesCommand, - WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, + ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiResponseDocumentAdmissionError, }; use crate::{ @@ -149,6 +150,14 @@ fn map_established_frame_result( result.map_err(WebDriverBiDiLocateNodesExchangeError::Frame) } +fn map_node_binding_error( + error: WebDriverBiDiLocateNodesAdmissionError, +) -> WebDriverBiDiLocateNodesExchangeError { + WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), + ) +} + impl WebDriverBiDiWebSocketEstablished { /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. /// @@ -299,11 +308,7 @@ impl WebDriverBiDiWebSocketEstablished { )?; let handles = result .bind_current_nodes(validated, authority_registry, target) - .map_err(|error| { - WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( - WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), - ) - })?; + .map_err(map_node_binding_error)?; Ok((established, handles)) } } From cd3734e61fc7d6c2ea7f6e637fa500836a90420b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:15:22 -0700 Subject: [PATCH 463/570] test(network): require in-wrapper binding conversion --- ...driver_bidi_locate_nodes_coverage_shape.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py b/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py index 254843349..2b5b4d815 100644 --- a/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py +++ b/tests/test_webdriver_bidi_locate_nodes_coverage_shape.py @@ -7,12 +7,12 @@ borrowed trait object at this boundary: its behavior remains stateful and caller owned without multiplying production coverage regions. -Likewise, error conversion at the public binding wrapper must use a named production -function rather than an inline closure. The library is linked into both unit and -integration-test harnesses; an inline closure can therefore acquire a second uncovered -instantiation even when the real fail-closed binding path is exercised. Keeping that -conversion named makes exact coverage represent product behavior rather than linker -instantiation shape. +The final node-binding error conversion must also avoid generating a separate closure +or helper function. The library is linked into both unit and integration-test +harnesses, and either form can acquire an uncovered duplicate instantiation even when +the real fail-closed binding path is exercised. A direct match inside the already +exercised public wrapper keeps the typed error conversion at the causal boundary +without adding another production symbol for coverage to duplicate. """ from __future__ import annotations @@ -44,10 +44,11 @@ def test_pong_entropy_callback_is_non_generic_at_exchange_boundary(self) -> None source, ) - def test_node_binding_error_conversion_is_named_not_inline_closure(self) -> None: + def test_node_binding_error_conversion_stays_inside_exercised_wrapper(self) -> None: source = SOURCE.read_text(encoding="utf-8") - self.assertIn("fn map_node_binding_error(", source) - self.assertIn(".map_err(map_node_binding_error)?;", source) + self.assertIn("let handles = match result.bind_current_nodes(", source) + self.assertNotIn("fn map_node_binding_error(", source) + self.assertNotIn(".map_err(map_node_binding_error)?;", source) self.assertNotIn(".map_err(|error| {", source) From 6400d2ef4e5fa35cc8ad5392aa03129e78b9103a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:17:35 -0700 Subject: [PATCH 464/570] fix(network): keep binding conversion in exercised wrapper --- .../webdriver_bidi_locate_nodes_exchange.rs | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 7e8118733..6c52ae76e 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -7,9 +7,8 @@ use std::{ use originweave_core::{ BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, ObservedNodeHandle, ValidatedBrowserProtocolUse, - ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesAdmissionError, - WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, - WebDriverBiDiResponseDocumentAdmissionError, + ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, }; use crate::{ @@ -150,14 +149,6 @@ fn map_established_frame_result( result.map_err(WebDriverBiDiLocateNodesExchangeError::Frame) } -fn map_node_binding_error( - error: WebDriverBiDiLocateNodesAdmissionError, -) -> WebDriverBiDiLocateNodesExchangeError { - WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( - WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), - ) -} - impl WebDriverBiDiWebSocketEstablished { /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. /// @@ -306,9 +297,14 @@ impl WebDriverBiDiWebSocketEstablished { next_pong_key, exchange_timeout, )?; - let handles = result - .bind_current_nodes(validated, authority_registry, target) - .map_err(map_node_binding_error)?; + let handles = match result.bind_current_nodes(validated, authority_registry, target) { + Ok(handles) => handles, + Err(error) => { + return Err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), + )); + } + }; Ok((established, handles)) } } From 54db15ae3a42973099ab14c29d06e752289c59c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:19:49 -0700 Subject: [PATCH 465/570] test(network): reproduce WebSocket nonce debug disclosure --- .../webdriver_bidi_websocket_handshake.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index 64c1bba6e..be447fd90 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -13,6 +13,7 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REDACTED_CLIENT_KEY: &str = ""; fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); @@ -43,6 +44,57 @@ fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { connection } +#[test] +fn client_key_debug_redacts_websocket_nonce() { + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + + let debug = format!("{key:?}"); + assert!(debug.contains(REDACTED_CLIENT_KEY)); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); +} + +#[test] +fn handshake_plan_debug_redacts_websocket_nonce() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let debug = format!("{plan:?}"); + assert!(debug.contains(REDACTED_CLIENT_KEY)); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + #[test] fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { let listener = TcpListener::bind(("127.0.0.1", 0)); From 3ade84e6e81bf94db96bf3411ddd394334b9cf98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:25:15 -0700 Subject: [PATCH 466/570] test(network): exercise binding wrapper in unit coverage crate --- ..._nodes_exchange_transport_failure_tests.rs | 104 +++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs index 1d84f1544..57041b676 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_transport_failure_tests.rs @@ -7,7 +7,10 @@ use std::{ }; use originweave_core::{ - WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, WebDriverBiDiWebSocketEndpoint, }; @@ -20,6 +23,11 @@ use crate::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; fn connect(endpoint: &str) -> Result> { let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; @@ -97,6 +105,29 @@ fn locate_nodes_command() -> Result Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + #[test] fn locate_nodes_exchange_preserves_initial_command_write_failure() -> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; @@ -263,3 +294,74 @@ fn zero_exchange_timeout_fails_at_exchange_boundary_before_frame_write() assert!(server_result.is_ok(), "{server_result:?}"); Ok(()) } + +#[test] +fn zero_exchange_timeout_binding_wrapper_executes_in_unit_crate_before_frame_write() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + let count = stream.read(&mut byte)?; + if count != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "zero-budget binding wrapper wrote a locateNodes client frame", + )); + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + + let exchanged = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::ZERO, + (semantic_observation_proof()?, target), + &mut registry, + ); + + let error = exchanged.err().ok_or_else(|| { + io::Error::other("zero-budget locateNodes binding wrapper unexpectedly succeeded") + })?; + assert!( + matches!( + &error, + WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + } if exchange_timeout.is_zero() + ), + "{error:?}" + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("zero-budget binding-wrapper test server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + Ok(()) +} From ba5b9be71c13b2ca0b109670ca4703feaed4404e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:28:48 -0700 Subject: [PATCH 467/570] fix(network): redact WebSocket nonce diagnostics --- .../src/webdriver_bidi_websocket_handshake.rs | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 3c826e626..d2826e425 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -16,6 +16,7 @@ use crate::{WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence}; const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; const RFC6455_WEBSOCKET_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; const MAX_WEBSOCKET_OPENING_RESPONSE_BYTES: usize = 16 * 1024; +const REDACTED_WEBSOCKET_CLIENT_NONCE: &str = ""; /// Maximum wall-clock budget accepted for writing one bounded WebSocket opening request. /// @@ -77,10 +78,20 @@ impl Error for WebDriverBiDiWebSocketHandshakeError {} /// RFC 6455 requires `Sec-WebSocket-Key` to be a nonce of 16 bytes encoded with base64. This type /// validates only the canonical wire representation, including zero padding bits. It does not /// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce -/// for each connection attempt. -#[derive(Debug, Eq, PartialEq)] +/// for each connection attempt. Its [`fmt::Debug`] representation deliberately redacts the nonce so +/// diagnostic output cannot disclose handshake material. +#[derive(Eq, PartialEq)] pub struct WebDriverBiDiWebSocketClientKey(String); +impl fmt::Debug for WebDriverBiDiWebSocketClientKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("WebDriverBiDiWebSocketClientKey") + .field(&REDACTED_WEBSOCKET_CLIENT_NONCE) + .finish() + } +} + impl WebDriverBiDiWebSocketClientKey { /// Admit one canonical base64 client key representing exactly 16 bytes. pub fn new(value: &str) -> Result { @@ -104,18 +115,29 @@ impl WebDriverBiDiWebSocketClientKey { /// the fixed WebSocket version-13 request required for the admitted `/session/` resource /// and retains the exact client key required to validate a later `Sec-WebSocket-Accept` response. /// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary -/// before any WebSocket bytes may be written. +/// before any WebSocket bytes may be written. Its [`fmt::Debug`] representation omits the serialized +/// request and redacts the client nonce because the request embeds that nonce in `Sec-WebSocket-Key`. /// /// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` /// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or /// Agent-authority grant. -#[derive(Debug)] pub struct WebDriverBiDiWebSocketHandshakePlan { connection: WebDriverBiDiTcpConnection, client_key: WebDriverBiDiWebSocketClientKey, request: Vec, } +impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketHandshakePlan") + .field("verified_peer", self.connection.verified_peer()) + .field("client_nonce", &REDACTED_WEBSOCKET_CLIENT_NONCE) + .field("request_byte_count", &self.request.len()) + .finish() + } +} + impl WebDriverBiDiWebSocketHandshakePlan { /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. pub fn new( From 853808eb8db23d52277fc300e9a92a25fb437548 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:31:08 -0700 Subject: [PATCH 468/570] test(network): cover successful binding wrapper in unit crate --- ...cate_nodes_exchange_unit_coverage_tests.rs | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs new file mode 100644 index 000000000..5afbbe8ec --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs @@ -0,0 +1,177 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; + +use crate::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one masked final client text frame", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test fixture rejects 64-bit client frame lengths", + )); + } + _ => unreachable!("7-bit WebSocket payload marker"), + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +#[test] +fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_client_text_frame(&mut stream)?; + let response = RESPONSE_DOCUMENT.as_bytes(); + let response_length = u8::try_from(response.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "test response exceeds short frame") + })?; + if response_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeds short frame", + )); + } + stream.write_all(&[0x81, response_length])?; + stream.write_all(response) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)? + .connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let session = registry.register_session(SESSION_ID)?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + + let (_established, handles) = established.exchange_locate_nodes_and_bind_current_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut || None, + Duration::from_millis(500), + (semantic_observation_proof()?, target), + &mut registry, + )?; + assert_eq!(handles.len(), 1); + assert_eq!(handles[0].origin(), &origin); + + server + .join() + .map_err(|_| io::Error::other("unit coverage test server panicked"))??; + Ok(()) +} From b6921622527e1ee39547e62b6e0dec49de841ea5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:31:31 -0700 Subject: [PATCH 469/570] test(network): register binding wrapper unit coverage --- crates/originweave-network/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index dba29c0c2..d8df2f977 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -19,6 +19,8 @@ mod webdriver_bidi_connection; mod webdriver_bidi_locate_nodes_exchange; #[cfg(test)] mod webdriver_bidi_locate_nodes_exchange_transport_failure_tests; +#[cfg(test)] +mod webdriver_bidi_locate_nodes_exchange_unit_coverage_tests; mod webdriver_bidi_websocket_control; #[cfg(test)] #[allow(clippy::expect_used)] From 18f2dff9b9564874f58ceee89186d4d612b9ca10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:35:38 -0700 Subject: [PATCH 470/570] test(network): apply canonical unit coverage rustfmt --- ...ver_bidi_locate_nodes_exchange_unit_coverage_tests.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs index 5afbbe8ec..7d4090db3 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs @@ -123,7 +123,10 @@ fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), B read_client_text_frame(&mut stream)?; let response = RESPONSE_DOCUMENT.as_bytes(); let response_length = u8::try_from(response.len()).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidData, "test response exceeds short frame") + io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeds short frame", + ) })?; if response_length > 125 { return Err(io::Error::new( @@ -139,8 +142,8 @@ fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), B let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; let correlated = admitted.correlate_session_id(SESSION_ID)?; let target = correlated.into_explicit_connect_target()?; - let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)? - .connect()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; let written = plan.write_opening_request(Duration::from_millis(500))?; From 5e0eab268482bade7356529281f740f42e716968 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:39:10 -0700 Subject: [PATCH 471/570] test(network): exercise locateNodes initial write rejection in integration --- ...i_locate_nodes_binding_exchange_failure.rs | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs index 50615339b..6c0a73559 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -15,12 +15,14 @@ use originweave_core::{ }; use originweave_network::{ WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, - WebDriverBiDiWebSocketMaskKey, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketFrameError, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REUSED_MASK_REASON: &str = + "client masking key was already used on this established WebSocket"; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = OriginWeaveProtocolVersion::new(0, 1); const ADAPTER_VERSION: &str = "originweave-bidi-v1"; @@ -92,6 +94,22 @@ fn read_client_text_frame(stream: &mut TcpStream) -> io::Result> { Ok(payload) } +fn require_peer_closed_without_another_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut byte = [0_u8; 1]; + match stream.read(&mut byte) { + Ok(0) => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "client emitted a second frame after fail-closed locateNodes command rejection", + )), + Err(error) => Err(io::Error::new( + error.kind(), + format!("client did not close after fail-closed locateNodes rejection: {error}"), + )), + } +} + fn establish_with_unexpected_binary_response() -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; @@ -153,6 +171,59 @@ fn semantic_observation_proof() -> Result Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result> { + let (mut stream, _) = listener.accept()?; + let request = read_opening_request(&mut stream)?; + if !request.ends_with(b"\r\n\r\n") { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client opening request was incomplete", + )); + } + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let first_frame = read_client_text_frame(&mut stream)?; + require_peer_closed_without_another_frame(&mut stream)?; + Ok(first_frame) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); + let established = established.write_text_frame( + "coverage-primer", + reused_mask, + Duration::from_millis(500), + )?; + + let error = established.exchange_locate_nodes( + locate_nodes_command()?, + reused_mask, + &mut || None, + Duration::from_millis(500), + ); + assert!(matches!( + error, + Err(WebDriverBiDiLocateNodesExchangeError::Frame( + WebDriverBiDiWebSocketFrameError::MalformedFrame { + reason: REUSED_MASK_REASON, + } + )) + )); + + let first_frame = server.join().map_err(|_| "test server panicked")??; + assert_eq!(first_frame, b"coverage-primer"); + Ok(()) +} + #[test] fn live_binding_wrapper_fails_closed_when_wire_exchange_fails_before_binding() -> Result<(), Box> { From 8385467af36dcceb2a1408d4ed7c924ea60d3743 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:40:42 -0700 Subject: [PATCH 472/570] test(network): prove WebSocket client nonce Debug redaction --- .../src/webdriver_bidi_websocket_debug_tests.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs index 61cba1ca9..991b239c5 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs @@ -10,6 +10,16 @@ use crate::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const CLIENT_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +#[test] +fn client_key_debug_redacts_client_nonce() { + let client_key = + WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY).expect("test client key must be valid"); + + let debug = format!("{client_key:?}"); + assert!(debug.contains("")); + assert!(!debug.contains(CLIENT_KEY)); +} + #[test] fn handshake_plan_debug_redacts_client_nonce() { let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); From d1150374b33c1437860402e82f6897343d2abf2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:40:51 -0700 Subject: [PATCH 473/570] test(network): apply canonical locateNodes integration rustfmt --- ...webdriver_bidi_locate_nodes_binding_exchange_failure.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs index 6c0a73559..7ead71ae7 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -198,11 +198,8 @@ fn locate_nodes_command_mask_reuse_fails_before_second_wire_write() -> Result<() let written = plan.write_opening_request(Duration::from_millis(500))?; let established = written.read_opening_response(Duration::from_millis(500))?; let reused_mask = WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); - let established = established.write_text_frame( - "coverage-primer", - reused_mask, - Duration::from_millis(500), - )?; + let established = + established.write_text_frame("coverage-primer", reused_mask, Duration::from_millis(500))?; let error = established.exchange_locate_nodes( locate_nodes_command()?, From 22bf20ec77be81858e627b9b1316af5e2fc92a82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:47:14 -0700 Subject: [PATCH 474/570] fix(network): redact WebSocket client nonce Debug --- .../src/webdriver_bidi_websocket_handshake.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 4e42217f9..92524564e 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -85,9 +85,15 @@ impl Error for WebDriverBiDiWebSocketHandshakeError {} /// validates only the canonical wire representation, including zero padding bits. It does not /// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce /// for each connection attempt. -#[derive(Debug, Eq, PartialEq)] +#[derive(Eq, PartialEq)] pub struct WebDriverBiDiWebSocketClientKey(String); +impl fmt::Debug for WebDriverBiDiWebSocketClientKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("") + } +} + impl WebDriverBiDiWebSocketClientKey { /// Admit one canonical base64 client key representing exactly 16 bytes. pub fn new(value: &str) -> Result { From 24d52f66a93d5118f8e8143c3e69ea0c4e19001a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 22:50:03 -0700 Subject: [PATCH 475/570] test(network): reject raw WebSocket request Debug exposure --- .../webdriver_bidi_websocket_debug_tests.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs index 991b239c5..3ed8b9061 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs @@ -5,6 +5,7 @@ use originweave_core::WebDriverBiDiWebSocketEndpoint; use crate::{ WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + webdriver_bidi_websocket_handshake_raw::WebDriverBiDiWebSocketHandshakePlan as RawWebDriverBiDiWebSocketHandshakePlan, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -20,6 +21,46 @@ fn client_key_debug_redacts_client_nonce() { assert!(!debug.contains(CLIENT_KEY)); } +#[test] +fn raw_handshake_plan_debug_omits_serialized_request() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let server = thread::spawn(move || { + listener + .accept() + .map(|_| ()) + .expect("test loopback connection must be accepted"); + }); + + let endpoint = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}")) + .expect("test endpoint must be valid"); + let correlated = endpoint + .correlate_session_id(SESSION_ID) + .expect("test session must correlate"); + let target = correlated + .into_explicit_connect_target() + .expect("test target must be explicit"); + let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("test connection plan must be valid") + .connect() + .expect("test connection must succeed"); + let client_key = + WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY).expect("test client key must be valid"); + let handshake = RawWebDriverBiDiWebSocketHandshakePlan::new(connection, client_key) + .expect("test raw handshake plan must be valid"); + + let debug = format!("{handshake:?}"); + assert!(debug.contains("")); + assert!(!debug.contains("request: [")); + assert!(!debug.contains(CLIENT_KEY)); + + drop(handshake); + server.join().expect("test server must not panic"); +} + #[test] fn handshake_plan_debug_redacts_client_nonce() { let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); From 370594cb3b12db11eedaf5fd5914454c01f3021d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:05:06 -0700 Subject: [PATCH 476/570] test(network): require fragmented BiDi response reassembly --- ...bdriver_bidi_locate_nodes_fragmentation.rs | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs new file mode 100644 index 000000000..6a32c4f8b --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs @@ -0,0 +1,176 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; + +type EstablishedFragmentServer = ( + originweave_network::WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client command was not one final masked text frame", + )); + } + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + let payload_length = u64::from_be_bytes(extended); + usize::try_from(payload_length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client command length did not fit usize", + ) + })? + } + marker => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected WebSocket length marker {marker}"), + )); + } + }; + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % mask.len()]; + } + Ok(payload) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn establish_with_fragmented_response() -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let _command = read_masked_client_text_frame(&mut stream)?; + + let response = RESPONSE_DOCUMENT.as_bytes(); + let split = response.len() / 2; + let first = &response[..split]; + let second = &response[split..]; + let first_length = u8::try_from(first.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "first response fragment exceeded one-byte test length", + ) + })?; + let second_length = u8::try_from(second.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "second response fragment exceeded one-byte test length", + ) + })?; + + let mut frames = Vec::with_capacity(response.len() + 4); + frames.extend_from_slice(&[0x01, first_length]); + frames.extend_from_slice(first); + frames.extend_from_slice(&[0x80, second_length]); + frames.extend_from_slice(second); + stream.write_all(&frames) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +#[test] +fn locate_nodes_exchange_reassembles_fragmented_text_response() -> Result<(), Box> { + let (established, server) = establish_with_fragmented_response()?; + let command = locate_nodes_command()?; + let mut no_pong_keys = || None; + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("fragmentation regression server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + assert!(exchanged.is_ok(), "{exchanged:?}"); + + let (_, result) = exchanged?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.nodes().len(), 1); + assert_eq!(result.nodes()[0].shared_id(), "shared-1"); + Ok(()) +} From 87a9171a148ff684c4fff85d4c9fed265ea9a615 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:10:41 -0700 Subject: [PATCH 477/570] fix(network): reassemble bounded fragmented BiDi responses --- .../webdriver_bidi_locate_nodes_exchange.rs | 115 +++++++++++++----- 1 file changed, 86 insertions(+), 29 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 6c52ae76e..ec5358751 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -6,9 +6,10 @@ use std::{ use originweave_core::{ BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, - BrowserContextOriginEpochDispatchTarget, ObservedNodeHandle, ValidatedBrowserProtocolUse, - ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesCommand, - WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, + BrowserContextOriginEpochDispatchTarget, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, + ObservedNodeHandle, ValidatedBrowserProtocolUse, ValidatedWebDriverBiDiLocateNodesResult, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiResponseDocumentAdmissionError, }; use crate::{ @@ -50,14 +51,14 @@ pub enum WebDriverBiDiLocateNodesExchangeError { PongMaskingKeyUnavailable, /// A caller supplied a Pong masking key already used by a client frame in this exchange. PongMaskingKeyReused, - /// The returned frame was neither an admissible control frame nor one complete text response. + /// The returned frame could not continue the one admissible text response message. UnexpectedResponseFrame { /// Whether the returned frame carried the RFC 6455 FIN bit. fin: bool, /// Exact returned RFC 6455 opcode. opcode: u8, }, - /// The exact response-frame payload failed bounded raw-document admission. + /// The exact response-message payload failed bounded raw-document admission. ResponseDocument(WebDriverBiDiResponseDocumentAdmissionError), /// The admitted response document failed parsing, exact correlation, or node admission. LocateNodesResponse(WebDriverBiDiLocateNodesResponseDocumentError), @@ -88,11 +89,11 @@ impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { ), Self::UnexpectedResponseFrame { fin, opcode } => write!( formatter, - "WebDriver BiDi locateNodes exchange requires control handling or one final text response frame; received fin={fin}, opcode=0x{opcode:02x}" + "WebDriver BiDi locateNodes exchange requires control handling or one bounded text response message; received fin={fin}, opcode=0x{opcode:02x}" ), Self::ResponseDocument(error) => write!( formatter, - "WebDriver BiDi locateNodes response frame failed raw-document admission: {error}" + "WebDriver BiDi locateNodes response message failed raw-document admission: {error}" ), Self::LocateNodesResponse(error) => write!( formatter, @@ -149,6 +150,30 @@ fn map_established_frame_result( result.map_err(WebDriverBiDiLocateNodesExchangeError::Frame) } +fn append_response_fragment( + response_message: &mut Vec, + payload: &[u8], +) -> Result<(), WebDriverBiDiLocateNodesExchangeError> { + if payload.len() > MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES - response_message.len() { + return Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge, + )); + } + response_message.extend_from_slice(payload); + Ok(()) +} + +fn admit_response_payload( + command: &WebDriverBiDiLocateNodesCommand, + payload: &[u8], +) -> Result { + let document = BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(payload) + .map_err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument)?; + command + .admit_response_document_nodes(document) + .map_err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse) +} + impl WebDriverBiDiWebSocketEstablished { /// Exchange one exact bounded `browsingContext.locateNodes` command on this verified stream. /// @@ -159,22 +184,29 @@ impl WebDriverBiDiWebSocketEstablished { /// `next_pong_key`; exhausting that caller-owned entropy source or repeating any key already used /// by the command or a prior Pong fails closed before another client frame is emitted. The caller /// remains responsible for generating each supplied key from a strong unpredictable entropy - /// source; exact non-reuse checks do not prove cryptographic unpredictability. Close, binary, - /// continuation, fragmented data, and reserved shapes are not reinterpreted as a BiDi response. + /// source; exact non-reuse checks do not prove cryptographic unpredictability. + /// + /// RFC 6455 text-message fragmentation is reassembled only for one response message at a time. + /// A non-final text frame starts that message, continuation frames extend it in order, and a final + /// continuation completes it. Ping/Pong control frames remain admissible between fragments. The + /// total assembled response is capped by [`MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES`] before + /// allocation can grow beyond the existing pre-parser budget. Orphan continuations, a second data + /// message before completion, binary/Close/reserved shapes, and malformed frame sequences fail + /// closed and consume the transport state. /// /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, - /// and response read. Elapsed time is subtracted before every operation, including the initial - /// command write, and the budget is never reset. Each individual frame operation is additionally - /// capped at the established frame timeout ceiling, so a longer end-to-end exchange budget - /// remains valid without widening the per-operation I/O bound. The underlying frame boundary - /// independently caps each frame at its existing size ceiling. In addition, at most - /// [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid Ping/Pong frames are processed before - /// the exchange fails closed, so RFC 6455 control-frame interleaving cannot create an unbounded - /// iteration budget even when the wall-clock deadline has not yet expired. Any failure consumes - /// this transport state and yields no reusable WebSocket stream, preventing a partially - /// written/read protocol state from becoming later authority. + /// response-fragment read, and response read. Elapsed time is subtracted before every operation, + /// including the initial command write, and the budget is never reset. Each individual frame + /// operation is additionally capped at the established frame timeout ceiling, so a longer + /// end-to-end exchange budget remains valid without widening the per-operation I/O bound. The + /// underlying frame boundary independently caps each frame at its existing size ceiling. In + /// addition, at most [`MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE`] valid Ping/Pong frames are + /// processed before the exchange fails closed, so RFC 6455 control-frame interleaving cannot + /// create an unbounded iteration budget even when the wall-clock deadline has not yet expired. + /// Any failure consumes this transport state and yields no reusable WebSocket stream, preventing + /// a partially written/read protocol state from becoming later authority. /// - /// The final complete text payload passes the existing bounded UTF-8/document admission, + /// The final complete text message passes the existing bounded UTF-8/document admission, /// complete WebDriver BiDi response parser, exact command-id correlation, and wire-derived node /// admission. Success returns the same exact peer-verified WebSocket stream plus untrusted /// normalized node evidence. It does not authenticate Chromium/ChromeDriver process provenance, @@ -202,6 +234,8 @@ impl WebDriverBiDiWebSocketEstablished { let mut used_client_masking_keys = [command_masking_key; MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1]; let mut used_client_masking_key_count = 1_usize; + let mut response_message = Vec::new(); + let mut assembling_text_response = false; loop { let remaining_timeout = @@ -242,15 +276,21 @@ impl WebDriverBiDiWebSocketEstablished { ))?; } 0xa => {} - 0x1 if frame.fin() => { - let document = - BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(frame.payload()) - .map_err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument)?; - let result = command - .admit_response_document_nodes(document) - .map_err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse)?; + 0x1 if !assembling_text_response && frame.fin() => { + let result = admit_response_payload(&command, frame.payload())?; return Ok((established, result)); } + 0x1 if !assembling_text_response => { + append_response_fragment(&mut response_message, frame.payload())?; + assembling_text_response = true; + } + 0x0 if assembling_text_response => { + append_response_fragment(&mut response_message, frame.payload())?; + if frame.fin() { + let result = admit_response_payload(&command, &response_message)?; + return Ok((established, result)); + } + } _ => { return Err( WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { @@ -314,14 +354,16 @@ mod tests { use std::{error::Error as _, time::Duration}; use originweave_core::{ - WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiResponseDocumentAdmissionError, + MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiResponseDocumentAdmissionError, }; use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; use super::{ MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, - next_pong_masking_key, remaining_exchange_budget, remaining_frame_operation_budget, + append_response_fragment, next_pong_masking_key, remaining_exchange_budget, + remaining_frame_operation_budget, }; #[test] @@ -363,6 +405,21 @@ mod tests { ); } + #[test] + fn response_fragment_buffer_never_exceeds_document_budget() { + let mut response = vec![0_u8; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES - 1]; + assert!(append_response_fragment(&mut response, b"x").is_ok()); + assert_eq!(response.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); + + assert!(matches!( + append_response_fragment(&mut response, b"y"), + Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge + )) + )); + assert_eq!(response.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); + } + #[test] fn pong_masking_key_source_fails_closed_when_entropy_is_unavailable() { let expected = crate::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); From 3e53784dd5f4a17bc15c281d1fbf86cdeb64b610 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:14:15 -0700 Subject: [PATCH 478/570] fix(network): preserve consuming BiDi command admission --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index ec5358751..c8a1086e2 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -164,7 +164,7 @@ fn append_response_fragment( } fn admit_response_payload( - command: &WebDriverBiDiLocateNodesCommand, + command: WebDriverBiDiLocateNodesCommand, payload: &[u8], ) -> Result { let document = BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(payload) @@ -277,7 +277,7 @@ impl WebDriverBiDiWebSocketEstablished { } 0xa => {} 0x1 if !assembling_text_response && frame.fin() => { - let result = admit_response_payload(&command, frame.payload())?; + let result = admit_response_payload(command, frame.payload())?; return Ok((established, result)); } 0x1 if !assembling_text_response => { @@ -287,7 +287,7 @@ impl WebDriverBiDiWebSocketEstablished { 0x0 if assembling_text_response => { append_response_fragment(&mut response_message, frame.payload())?; if frame.fin() { - let result = admit_response_payload(&command, &response_message)?; + let result = admit_response_payload(command, &response_message)?; return Ok((established, result)); } } From 1a3d80a5357ec7ba369b9863cd17b2fb9410dcba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:18:00 -0700 Subject: [PATCH 479/570] test(network): keep fragmented BiDi response semantics current --- .../tests/webdriver_bidi_websocket_locate_nodes_exchange.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index 6a6436c16..9185029b8 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -296,9 +296,9 @@ fn exchange_deadline_is_not_reset_after_the_frame_write() { } #[test] -fn exchange_rejects_a_non_final_or_non_text_response_frame() { +fn exchange_rejects_binary_or_orphan_continuation_response_frames() { for (first_byte, expected_fin, expected_opcode) in - [(0x01_u8, false, 0x01_u8), (0x82_u8, true, 0x02_u8)] + [(0x82_u8, true, 0x02_u8), (0x80_u8, true, 0x00_u8)] { let response_frame = server_frame(first_byte, &[]); assert!(response_frame.is_ok(), "{response_frame:?}"); From 0e797772ed727c520048aae22ae2c93ab152f69e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:20:20 -0700 Subject: [PATCH 480/570] test(network): exercise Ping between BiDi response fragments --- ...bdriver_bidi_locate_nodes_fragmentation.rs | 145 +++++++++++++----- 1 file changed, 109 insertions(+), 36 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs index 6a32c4f8b..5e10a7c9e 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs @@ -19,6 +19,7 @@ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const RESPONSE_DOCUMENT: &str = r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; +const PING_PAYLOAD: &[u8] = b"ping-between-fragments"; type EstablishedFragmentServer = ( originweave_network::WebDriverBiDiWebSocketEstablished, @@ -52,14 +53,14 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } -fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result> { +fn read_masked_client_frame(stream: &mut TcpStream, expected_first_byte: u8) -> io::Result> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; let mut header = [0_u8; 2]; stream.read_exact(&mut header)?; - if header[0] != 0x81 || header[1] & 0x80 == 0 { + if header[0] != expected_first_byte || header[1] & 0x80 == 0 { return Err(io::Error::new( io::ErrorKind::InvalidData, - "client command was not one final masked text frame", + "client frame did not use the expected final masked opcode", )); } let payload_length = match header[1] & 0x7f { @@ -76,7 +77,7 @@ fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result> usize::try_from(payload_length).map_err(|_| { io::Error::new( io::ErrorKind::InvalidData, - "client command length did not fit usize", + "client frame length did not fit usize", ) })? } @@ -97,6 +98,10 @@ fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result> Ok(payload) } +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result> { + read_masked_client_frame(stream, 0x81) +} + fn locate_nodes_command() -> Result> { let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; Ok(WebDriverBiDiLocateNodesCommand::new( @@ -106,6 +111,33 @@ fn locate_nodes_command() -> Result io::Result<(Vec, Vec)> { + let response = RESPONSE_DOCUMENT.as_bytes(); + let split = response.len() / 2; + let first = &response[..split]; + let second = &response[split..]; + let first_length = u8::try_from(first.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "first response fragment exceeded one-byte test length", + ) + })?; + let second_length = u8::try_from(second.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "second response fragment exceeded one-byte test length", + ) + })?; + + let mut first_frame = Vec::with_capacity(first.len() + 2); + first_frame.extend_from_slice(&[0x01, first_length]); + first_frame.extend_from_slice(first); + let mut second_frame = Vec::with_capacity(second.len() + 2); + second_frame.extend_from_slice(&[0x80, second_length]); + second_frame.extend_from_slice(second); + Ok((first_frame, second_frame)) +} + fn establish_with_fragmented_response() -> Result> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; @@ -116,30 +148,45 @@ fn establish_with_fragmented_response() -> Result Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + let _command = read_masked_client_text_frame(&mut stream)?; + let (first_frame, second_frame) = response_fragments()?; + stream.write_all(&first_frame)?; + let ping_length = u8::try_from(PING_PAYLOAD.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "test Ping payload exceeded 125 bytes") })?; + stream.write_all(&[0x89, ping_length])?; + stream.write_all(PING_PAYLOAD)?; - let mut frames = Vec::with_capacity(response.len() + 4); - frames.extend_from_slice(&[0x01, first_length]); - frames.extend_from_slice(first); - frames.extend_from_slice(&[0x80, second_length]); - frames.extend_from_slice(second); - stream.write_all(&frames) + let pong_payload = read_masked_client_frame(&mut stream, 0x8a)?; + if pong_payload != PING_PAYLOAD { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client Pong did not echo the interleaved Ping payload", + )); + } + stream.write_all(&second_frame) }); let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); @@ -150,6 +197,29 @@ fn establish_with_fragmented_response() -> Result, + server: thread::JoinHandle>, +) -> Result<(), Box> { + let server_result = server + .join() + .map_err(|_| io::Error::other("fragmentation regression server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + assert!(exchanged.is_ok(), "{exchanged:?}"); + + let (_, result) = exchanged?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.nodes().len(), 1); + assert_eq!(result.nodes()[0].shared_id(), "shared-1"); + Ok(()) +} + #[test] fn locate_nodes_exchange_reassembles_fragmented_text_response() -> Result<(), Box> { let (established, server) = establish_with_fragmented_response()?; @@ -161,16 +231,19 @@ fn locate_nodes_exchange_reassembles_fragmented_text_response() -> Result<(), Bo &mut no_pong_keys, Duration::from_millis(500), ); + assert_successful_exchange(exchanged, server) +} - let server_result = server - .join() - .map_err(|_| io::Error::other("fragmentation regression server panicked"))?; - assert!(server_result.is_ok(), "{server_result:?}"); - assert!(exchanged.is_ok(), "{exchanged:?}"); - - let (_, result) = exchanged?; - assert_eq!(result.command_id(), 7); - assert_eq!(result.nodes().len(), 1); - assert_eq!(result.nodes()[0].shared_id(), "shared-1"); - Ok(()) +#[test] +fn locate_nodes_exchange_handles_ping_between_response_fragments() -> Result<(), Box> { + let (established, server) = establish_with_ping_between_fragments()?; + let command = locate_nodes_command()?; + let mut pong_keys = || Some(WebDriverBiDiWebSocketMaskKey::new([0x55, 0x66, 0x77, 0x88])); + let exchanged = established.exchange_locate_nodes( + command, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut pong_keys, + Duration::from_millis(500), + ); + assert_successful_exchange(exchanged, server) } From 8e061d68cb82731e54da43a336e29d960b663125 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 01:22:26 -0700 Subject: [PATCH 481/570] style(network): apply canonical fragmentation test formatting --- .../tests/webdriver_bidi_locate_nodes_fragmentation.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs index 5e10a7c9e..b1c98b61a 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation.rs @@ -53,7 +53,10 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } -fn read_masked_client_frame(stream: &mut TcpStream, expected_first_byte: u8) -> io::Result> { +fn read_masked_client_frame( + stream: &mut TcpStream, + expected_first_byte: u8, +) -> io::Result> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; let mut header = [0_u8; 2]; stream.read_exact(&mut header)?; @@ -174,7 +177,10 @@ fn establish_with_ping_between_fragments() -> Result Date: Wed, 26 Aug 2026 02:12:58 -0700 Subject: [PATCH 482/570] test(network): cover fragmented locateNodes boundaries --- ...i_locate_nodes_fragmentation_boundaries.rs | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs new file mode 100644 index 000000000..fc9c9a7b1 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs @@ -0,0 +1,269 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiResponseDocumentAdmissionError, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; + +type EstablishedFrameServer = ( + WebDriverBiDiWebSocketEstablished, + thread::JoinHandle>, +); + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + usize::try_from(u64::from_be_bytes(extended)).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client frame payload length did not fit usize", + ) + })? + } + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) +} + +fn server_frame(fin: bool, opcode: u8, payload: &[u8]) -> io::Result> { + let first_byte = if fin { 0x80 | opcode } else { opcode }; + let mut frame = Vec::with_capacity(payload.len() + 10); + frame.push(first_byte); + + if payload.len() <= 125 { + frame.push(u8::try_from(payload.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "short server frame length did not fit u8", + ) + })?); + } else if payload.len() <= usize::from(u16::MAX) { + frame.push(126); + frame.extend_from_slice( + &u16::try_from(payload.len()) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "medium server frame length did not fit u16", + ) + })? + .to_be_bytes(), + ); + } else { + frame.push(127); + frame.extend_from_slice( + &u64::try_from(payload.len()) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "large server frame length did not fit u64", + ) + })? + .to_be_bytes(), + ); + } + + frame.extend_from_slice(payload); + Ok(frame) +} + +fn establish_with_frames(frames: Vec>) -> Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_text_frame(&mut stream)?; + for frame in frames { + stream.write_all(&frame)?; + } + Ok(()) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + Ok((established, server)) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +fn join_server(server: thread::JoinHandle>) -> Result<(), Box> { + server + .join() + .map_err(|_| io::Error::other("fragmentation boundary server panicked"))??; + Ok(()) +} + +#[test] +fn three_fragment_response_reassembles() -> Result<(), Box> { + let response = RESPONSE_DOCUMENT.as_bytes(); + let first_end = response.len() / 3; + let second_end = first_end * 2; + let frames = vec![ + server_frame(false, 0x1, &response[..first_end])?, + server_frame(false, 0x0, &response[first_end..second_end])?, + server_frame(true, 0x0, &response[second_end..])?, + ]; + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + + join_server(server)?; + let (_, result) = exchange?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.nodes().len(), 1); + Ok(()) +} + +#[test] +fn oversized_initial_fragment_fails_closed() -> Result<(), Box> { + let payload = vec![b'{'; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1]; + let frames = vec![server_frame(false, 0x1, &payload)?]; + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + + join_server(server)?; + assert!(matches!( + exchange, + Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge + )) + )); + Ok(()) +} + +#[test] +fn fragmented_response_over_budget_fails_closed() -> Result<(), Box> { + let first_payload = vec![b'{'; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES]; + let frames = vec![ + server_frame(false, 0x1, &first_payload)?, + server_frame(true, 0x0, b"}")?, + ]; + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + + join_server(server)?; + assert!(matches!( + exchange, + Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge + )) + )); + Ok(()) +} + +#[test] +fn fragmented_invalid_utf8_fails_closed() -> Result<(), Box> { + let frames = vec![ + server_frame(false, 0x1, b"{")?, + server_frame(true, 0x0, &[0xff, b'}'])?, + ]; + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_millis(500), + ); + + join_server(server)?; + assert!(matches!( + exchange, + Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( + WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8 + )) + )); + Ok(()) +} From 15914957feb2039192bd460fdc862bc554c60f5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 02:18:11 -0700 Subject: [PATCH 483/570] test(network): fail closed on impossible frame marker --- .../webdriver_bidi_locate_nodes_fragmentation_boundaries.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs index fc9c9a7b1..a167d526e 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs @@ -72,6 +72,12 @@ fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { ) })? } + marker => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid client frame payload-length marker {marker}"), + )); + } }; let mut mask = [0_u8; 4]; From 4c1558d74bc503af32f90a9b7ac6e1bfacfac3d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 02:35:02 -0700 Subject: [PATCH 484/570] test(network): remove vacuous matches coverage branch --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index c8a1086e2..3b84a6c7f 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -411,12 +411,10 @@ mod tests { assert!(append_response_fragment(&mut response, b"x").is_ok()); assert_eq!(response.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); - assert!(matches!( - append_response_fragment(&mut response, b"y"), - Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( - WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge - )) - )); + assert_eq!( + format!("{:?}", append_response_fragment(&mut response, b"y")), + "Err(ResponseDocument(DocumentTooLarge))" + ); assert_eq!(response.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); } From f653c8e6da39690647d939f98f22be8b7135b12f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 02:36:05 -0700 Subject: [PATCH 485/570] test(network): cover fragmented locateNodes unit branches --- ...cate_nodes_exchange_unit_coverage_tests.rs | 130 ++++++++++++++---- 1 file changed, 102 insertions(+), 28 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs index 7d4090db3..d29336c92 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs @@ -78,6 +78,29 @@ fn read_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } +fn write_opening_response(stream: &mut TcpStream) -> io::Result<()> { + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + ) +} + +fn write_short_server_frame(stream: &mut TcpStream, first_byte: u8, payload: &[u8]) -> io::Result<()> { + let payload_length = u8::try_from(payload.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "unit coverage server frame exceeds short-frame limit", + ) + })?; + if payload_length > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unit coverage server frame exceeds short-frame limit", + )); + } + stream.write_all(&[first_byte, payload_length])?; + stream.write_all(payload) +} + fn locate_nodes_command() -> Result> { let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; Ok(WebDriverBiDiLocateNodesCommand::new( @@ -110,6 +133,21 @@ fn semantic_observation_proof() -> Result Result> { + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + Ok(written.read_opening_response(Duration::from_millis(500))?) +} + #[test] fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; @@ -117,37 +155,12 @@ fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), B let server = thread::spawn(move || -> io::Result<()> { let (mut stream, _) = listener.accept()?; read_opening_request(&mut stream)?; - stream.write_all( - b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", - )?; + write_opening_response(&mut stream)?; read_client_text_frame(&mut stream)?; - let response = RESPONSE_DOCUMENT.as_bytes(); - let response_length = u8::try_from(response.len()).map_err(|_| { - io::Error::new( - io::ErrorKind::InvalidData, - "test response exceeds short frame", - ) - })?; - if response_length > 125 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "test response exceeds short frame", - )); - } - stream.write_all(&[0x81, response_length])?; - stream.write_all(response) + write_short_server_frame(&mut stream, 0x81, RESPONSE_DOCUMENT.as_bytes()) }); - let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); - let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; - let correlated = admitted.correlate_session_id(SESSION_ID)?; - let target = correlated.into_explicit_connect_target()?; - let connection = - WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; - let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; - let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; - let written = plan.write_opening_request(Duration::from_millis(500))?; - let established = written.read_opening_response(Duration::from_millis(500))?; + let established = establish_client(local_addr)?; let mut registry = BrowserAuthorityRegistry::new(); let origin = controlled_origin()?; @@ -178,3 +191,64 @@ fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), B .map_err(|_| io::Error::other("unit coverage test server panicked"))??; Ok(()) } + +#[test] +fn fragmented_response_executes_nonfinal_text_arm_in_library_unit_crate() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + write_opening_response(&mut stream)?; + read_client_text_frame(&mut stream)?; + let response = RESPONSE_DOCUMENT.as_bytes(); + write_short_server_frame(&mut stream, 0x01, &response[..1])?; + write_short_server_frame(&mut stream, 0x80, &response[1..]) + }); + + let established = establish_client(local_addr)?; + let (_established, result) = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x21, 0x22, 0x23, 0x24]), + &mut || None, + Duration::from_millis(500), + )?; + assert_eq!(result.nodes().len(), 1); + + server + .join() + .map_err(|_| io::Error::other("fragmented unit coverage test server panicked"))??; + Ok(()) +} + +#[test] +fn second_text_frame_during_fragmentation_executes_guard_denial_in_library_unit_crate( +) -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + write_opening_response(&mut stream)?; + read_client_text_frame(&mut stream)?; + write_short_server_frame(&mut stream, 0x01, b"{")?; + write_short_server_frame(&mut stream, 0x81, b"x") + }); + + let established = establish_client(local_addr)?; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x31, 0x32, 0x33, 0x34]), + &mut || None, + Duration::from_millis(500), + ); + assert_eq!( + format!("{exchange:?}"), + "Err(UnexpectedResponseFrame { fin: true, opcode: 1 })" + ); + + server + .join() + .map_err(|_| io::Error::other("second-text unit coverage test server panicked"))??; + Ok(()) +} From 3e71e63d6f89d55fa8095334a9eae1e312b2233d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 02:38:12 -0700 Subject: [PATCH 486/570] style(network): apply canonical rustfmt to BiDi coverage tests --- ...idi_locate_nodes_exchange_unit_coverage_tests.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs index d29336c92..bda5c2b98 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange_unit_coverage_tests.rs @@ -84,7 +84,11 @@ fn write_opening_response(stream: &mut TcpStream) -> io::Result<()> { ) } -fn write_short_server_frame(stream: &mut TcpStream, first_byte: u8, payload: &[u8]) -> io::Result<()> { +fn write_short_server_frame( + stream: &mut TcpStream, + first_byte: u8, + payload: &[u8], +) -> io::Result<()> { let payload_length = u8::try_from(payload.len()).map_err(|_| { io::Error::new( io::ErrorKind::InvalidData, @@ -193,7 +197,8 @@ fn binding_wrapper_success_path_executes_in_library_unit_crate() -> Result<(), B } #[test] -fn fragmented_response_executes_nonfinal_text_arm_in_library_unit_crate() -> Result<(), Box> { +fn fragmented_response_executes_nonfinal_text_arm_in_library_unit_crate() +-> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { @@ -222,8 +227,8 @@ fn fragmented_response_executes_nonfinal_text_arm_in_library_unit_crate() -> Res } #[test] -fn second_text_frame_during_fragmentation_executes_guard_denial_in_library_unit_crate( -) -> Result<(), Box> { +fn second_text_frame_during_fragmentation_executes_guard_denial_in_library_unit_crate() +-> Result<(), Box> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let local_addr = listener.local_addr()?; let server = thread::spawn(move || -> io::Result<()> { From f537a4d939e832154feea09c0f7d30aa70920893 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 02:50:36 -0700 Subject: [PATCH 487/570] test(network): reject second text message during BiDi fragmentation --- ...idi_locate_nodes_fragmented_second_text.rs | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs new file mode 100644 index 000000000..1f201ae72 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs @@ -0,0 +1,139 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiWebSocketEndpoint, +}; +use originweave_network::{ + WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_masked_client_text_frame(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + let mut header = [0_u8; 2]; + stream.read_exact(&mut header)?; + if header[0] != 0x81 || header[1] & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected one final masked client text frame", + )); + } + + let payload_length = match header[1] & 0x7f { + value @ 0..=125 => usize::from(value), + 126 => { + let mut extended = [0_u8; 2]; + stream.read_exact(&mut extended)?; + usize::from(u16::from_be_bytes(extended)) + } + 127 => { + let mut extended = [0_u8; 8]; + stream.read_exact(&mut extended)?; + usize::try_from(u64::from_be_bytes(extended)).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "client frame payload length did not fit usize", + ) + })? + } + marker => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid client frame payload-length marker {marker}"), + )); + } + }; + + let mut mask = [0_u8; 4]; + stream.read_exact(&mut mask)?; + let mut payload = vec![0_u8; payload_length]; + stream.read_exact(&mut payload)?; + Ok(()) +} + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 7, + "top-level-context", + &query, + )?) +} + +#[test] +fn second_text_message_during_fragmentation_fails_closed() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_text_frame(&mut stream)?; + stream.write_all(&[0x01, 0x01, b'{'])?; + stream.write_all(&[0x81, 0x01, b'x']) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x31, 0x32, 0x33, 0x34]), + &mut || None, + Duration::from_millis(500), + ); + + server + .join() + .map_err(|_| io::Error::other("fragmented second-text server panicked"))??; + assert_eq!( + format!("{exchange:?}"), + "Err(UnexpectedResponseFrame { fin: true, opcode: 1 })" + ); + assert!(matches!( + exchange, + Err(WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { + fin: true, + opcode: 0x1 + }) + )); + Ok(()) +} From 2f1ff590fc0c42e457f04c22379f2fe190cdce22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 03:03:30 -0700 Subject: [PATCH 488/570] style(network): apply canonical rustfmt to fragmentation regression --- ...bdriver_bidi_locate_nodes_fragmented_second_text.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs index 1f201ae72..15df8cad0 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmented_second_text.rs @@ -130,10 +130,12 @@ fn second_text_message_during_fragmentation_fails_closed() -> Result<(), Box Date: Wed, 26 Aug 2026 04:13:11 -0700 Subject: [PATCH 489/570] test(network): bound locateNodes response fragmentation --- ...i_locate_nodes_fragmentation_boundaries.rs | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs index a167d526e..f959dcc1b 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs @@ -12,9 +12,10 @@ use originweave_core::{ WebDriverBiDiWebSocketEndpoint, }; use originweave_network::{ - WebDriverBiDiLocateNodesExchangeError, WebDriverBiDiTcpConnectionPlan, - WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketEstablished, - WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, + MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketMaskKey, }; const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; @@ -273,3 +274,33 @@ fn fragmented_invalid_utf8_fails_closed() -> Result<(), Box> { )); Ok(()) } + +#[test] +fn excessive_zero_length_fragments_fail_closed() -> Result<(), Box> { + let mut frames = + Vec::with_capacity(MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE + 1); + frames.push(server_frame(false, 0x1, b"")?); + for _ in 1..=MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE { + frames.push(server_frame(false, 0x0, b"")?); + } + + let (established, server) = establish_with_frames(frames)?; + let mut no_pong_keys = || None; + let exchange = established.exchange_locate_nodes( + locate_nodes_command()?, + WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), + &mut no_pong_keys, + Duration::from_secs(2), + ); + + join_server(server)?; + assert!(matches!( + exchange, + Err( + WebDriverBiDiLocateNodesExchangeError::ResponseFragmentLimitExceeded { + maximum_fragments: MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, + } + ) + )); + Ok(()) +} From ce027d58cdbe522d5968e091ed05a02de74f3997 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:15:54 -0700 Subject: [PATCH 490/570] style(network): apply canonical rustfmt to fragmentation budget regression --- .../webdriver_bidi_locate_nodes_fragmentation_boundaries.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs index f959dcc1b..eedd13fd8 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_fragmentation_boundaries.rs @@ -277,8 +277,7 @@ fn fragmented_invalid_utf8_fails_closed() -> Result<(), Box> { #[test] fn excessive_zero_length_fragments_fail_closed() -> Result<(), Box> { - let mut frames = - Vec::with_capacity(MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE + 1); + let mut frames = Vec::with_capacity(MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE + 1); frames.push(server_frame(false, 0x1, b"")?); for _ in 1..=MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE { frames.push(server_frame(false, 0x0, b"")?); From 9fa4e461f11a55ba31d09ab65ec764d53649b2d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:20:50 -0700 Subject: [PATCH 491/570] fix(network): bound locateNodes response fragment count --- .../webdriver_bidi_locate_nodes_exchange.rs | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 3b84a6c7f..d37cb9f25 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -25,6 +25,13 @@ use crate::{ /// independent wall-clock bound. pub const MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE: usize = 64; +/// Maximum number of data fragments accepted for one `locateNodes` response message. +/// +/// RFC 6455 permits a text message to be split into an arbitrary number of continuation frames, +/// including empty fragments. This OriginWeave product-safety budget prevents a peer from turning +/// bounded response bytes into unbounded frame-processing work before the end-to-end deadline. +pub const MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE: usize = 256; + /// Fail-closed failures while exchanging one bounded WebDriver BiDi `locateNodes` command. /// /// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded @@ -47,6 +54,11 @@ pub enum WebDriverBiDiLocateNodesExchangeError { /// Maximum number of control frames admitted for one exchange. maximum_control_frames: usize, }, + /// The peer exceeded the local resource budget for response-message data fragments. + ResponseFragmentLimitExceeded { + /// Maximum number of response-message data fragments admitted for one exchange. + maximum_fragments: usize, + }, /// A server Ping required a fresh client masking key, but the caller supplied none. PongMaskingKeyUnavailable, /// A caller supplied a Pong masking key already used by a client frame in this exchange. @@ -81,6 +93,10 @@ impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { formatter, "WebDriver BiDi locateNodes exchange exceeded the maximum {maximum_control_frames} interleaved control frames" ), + Self::ResponseFragmentLimitExceeded { maximum_fragments } => write!( + formatter, + "WebDriver BiDi locateNodes exchange exceeded the maximum {maximum_fragments} response-message data fragments" + ), Self::PongMaskingKeyUnavailable => formatter.write_str( "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key", ), @@ -111,6 +127,7 @@ impl Error for WebDriverBiDiLocateNodesExchangeError { Self::LocateNodesResponse(error) => Some(error), Self::ExchangeDeadlineExceeded { .. } | Self::ControlFrameLimitExceeded { .. } + | Self::ResponseFragmentLimitExceeded { .. } | Self::PongMaskingKeyUnavailable | Self::PongMaskingKeyReused | Self::UnexpectedResponseFrame { .. } => None, @@ -150,6 +167,20 @@ fn map_established_frame_result( result.map_err(WebDriverBiDiLocateNodesExchangeError::Frame) } +fn admit_response_fragment( + response_fragment_count: &mut usize, +) -> Result<(), WebDriverBiDiLocateNodesExchangeError> { + if *response_fragment_count == MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE { + return Err( + WebDriverBiDiLocateNodesExchangeError::ResponseFragmentLimitExceeded { + maximum_fragments: MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, + }, + ); + } + *response_fragment_count += 1; + Ok(()) +} + fn append_response_fragment( response_message: &mut Vec, payload: &[u8], @@ -190,9 +221,11 @@ impl WebDriverBiDiWebSocketEstablished { /// A non-final text frame starts that message, continuation frames extend it in order, and a final /// continuation completes it. Ping/Pong control frames remain admissible between fragments. The /// total assembled response is capped by [`MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES`] before - /// allocation can grow beyond the existing pre-parser budget. Orphan continuations, a second data - /// message before completion, binary/Close/reserved shapes, and malformed frame sequences fail - /// closed and consume the transport state. + /// allocation can grow beyond the existing pre-parser budget, and at most + /// [`MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE`] accepted data fragments may compose the + /// message, so empty continuation frames cannot create unbounded processing work. Orphan + /// continuations, a second data message before completion, binary/Close/reserved shapes, and + /// malformed frame sequences fail closed and consume the transport state. /// /// `exchange_timeout` is one end-to-end budget for every command write, control-frame read/write, /// response-fragment read, and response read. Elapsed time is subtracted before every operation, @@ -234,6 +267,7 @@ impl WebDriverBiDiWebSocketEstablished { let mut used_client_masking_keys = [command_masking_key; MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1]; let mut used_client_masking_key_count = 1_usize; + let mut response_fragment_count = 0_usize; let mut response_message = Vec::new(); let mut assembling_text_response = false; @@ -277,14 +311,17 @@ impl WebDriverBiDiWebSocketEstablished { } 0xa => {} 0x1 if !assembling_text_response && frame.fin() => { + admit_response_fragment(&mut response_fragment_count)?; let result = admit_response_payload(command, frame.payload())?; return Ok((established, result)); } 0x1 if !assembling_text_response => { + admit_response_fragment(&mut response_fragment_count)?; append_response_fragment(&mut response_message, frame.payload())?; assembling_text_response = true; } 0x0 if assembling_text_response => { + admit_response_fragment(&mut response_fragment_count)?; append_response_fragment(&mut response_message, frame.payload())?; if frame.fin() { let result = admit_response_payload(command, &response_message)?; @@ -361,7 +398,8 @@ mod tests { use crate::{MAX_WEBSOCKET_FRAME_TIMEOUT, WebDriverBiDiWebSocketFrameError}; use super::{ - MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, append_response_fragment, next_pong_masking_key, remaining_exchange_budget, remaining_frame_operation_budget, }; @@ -462,6 +500,16 @@ mod tests { .contains("maximum 64 interleaved control frames") ); + let fragment_limit = WebDriverBiDiLocateNodesExchangeError::ResponseFragmentLimitExceeded { + maximum_fragments: MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, + }; + assert!(fragment_limit.source().is_none()); + assert!( + fragment_limit + .to_string() + .contains("maximum 256 response-message data fragments") + ); + let missing_mask = WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable; assert!(missing_mask.source().is_none()); assert!( From 3e504310bb4d07372b401f852c863197eaebdde4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:21:39 -0700 Subject: [PATCH 492/570] feat(network): export locateNodes response fragment budget --- crates/originweave-network/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 872073d98..3d2aac226 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -42,7 +42,8 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; pub use webdriver_bidi_locate_nodes_exchange::{ - MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, + MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, + MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, }; pub use webdriver_bidi_websocket_handshake::{ WebDriverBiDiWebSocketEstablished, WebDriverBiDiWebSocketHandshakePlan, From 078648b411c2fed874b77afdad3f31feb819c719 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 04:33:33 -0700 Subject: [PATCH 493/570] fix(network): remove unreachable first-fragment error edges --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index d37cb9f25..358e7ff56 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -311,12 +311,11 @@ impl WebDriverBiDiWebSocketEstablished { } 0xa => {} 0x1 if !assembling_text_response && frame.fin() => { - admit_response_fragment(&mut response_fragment_count)?; let result = admit_response_payload(command, frame.payload())?; return Ok((established, result)); } 0x1 if !assembling_text_response => { - admit_response_fragment(&mut response_fragment_count)?; + response_fragment_count = 1; append_response_fragment(&mut response_message, frame.payload())?; assembling_text_response = true; } From 02952da6f4c942fe990f7350dd0af9a98cbc1f99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:13:11 -0700 Subject: [PATCH 494/570] test(network): redact WebSocket masking-key diagnostics --- .../src/webdriver_bidi_websocket_debug_tests.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs index 3ed8b9061..0f8bb957e 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs @@ -4,7 +4,7 @@ use originweave_core::WebDriverBiDiWebSocketEndpoint; use crate::{ WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, webdriver_bidi_websocket_handshake_raw::WebDriverBiDiWebSocketHandshakePlan as RawWebDriverBiDiWebSocketHandshakePlan, }; @@ -21,6 +21,18 @@ fn client_key_debug_redacts_client_nonce() { assert!(!debug.contains(CLIENT_KEY)); } +#[test] +fn masking_key_debug_redacts_frame_entropy() { + let masking_key = WebDriverBiDiWebSocketMaskKey::new([17, 34, 51, 68]); + + let debug = format!("{masking_key:?}"); + assert!(debug.contains("")); + assert!(!debug.contains("17")); + assert!(!debug.contains("34")); + assert!(!debug.contains("51")); + assert!(!debug.contains("68")); +} + #[test] fn raw_handshake_plan_debug_omits_serialized_request() { let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); From 5fe9db91ef3d9bc2c6ff71163f617cf035296560 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:32:06 -0700 Subject: [PATCH 495/570] fix(network): redact public WebSocket masking-key diagnostics --- .../src/webdriver_bidi_websocket_mask_key.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_mask_key.rs diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_mask_key.rs b/crates/originweave-network/src/webdriver_bidi_websocket_mask_key.rs new file mode 100644 index 000000000..8f0f437d1 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_mask_key.rs @@ -0,0 +1,36 @@ +use std::fmt; + +use crate::webdriver_bidi_websocket_handshake_raw as raw; + +/// Caller-supplied RFC 6455 mask key for one client-to-server frame. +/// +/// RFC 6455 requires every client frame to carry a fresh, unpredictable four-byte key. This +/// public wrapper keeps those bytes available only to the framing boundary while ensuring generic +/// diagnostics cannot render the masking entropy. Callers remain responsible for obtaining a fresh +/// key from an approved randomness source for every client frame. +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketMaskKey(raw::WebDriverBiDiWebSocketMaskKey); + +impl fmt::Debug for WebDriverBiDiWebSocketMaskKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("") + } +} + +impl WebDriverBiDiWebSocketMaskKey { + /// Admit one four-byte caller-supplied frame masking key. + #[must_use] + pub const fn new(value: [u8; 4]) -> Self { + Self(raw::WebDriverBiDiWebSocketMaskKey::new(value)) + } + + /// Borrow the exact four-byte key for the reviewed wire-framing boundary. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 4] { + self.0.as_bytes() + } + + pub(crate) const fn into_raw(self) -> raw::WebDriverBiDiWebSocketMaskKey { + self.0 + } +} From c051c157a43db63c6f6ee1f91a362b69d0fb641d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:32:33 -0700 Subject: [PATCH 496/570] fix(network): expose redacted WebSocket mask-key wrapper --- crates/originweave-network/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 30048eff0..eafdac09d 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -26,6 +26,7 @@ mod webdriver_bidi_websocket_debug_tests; mod webdriver_bidi_websocket_handshake; #[path = "webdriver_bidi_websocket_raw_redacted.rs"] mod webdriver_bidi_websocket_handshake_raw; +mod webdriver_bidi_websocket_mask_key; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, @@ -45,5 +46,6 @@ pub use webdriver_bidi_websocket_handshake_raw::{ MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketFrame, WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, - WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningWriteError, + WebDriverBiDiWebSocketOpeningWriteError, }; +pub use webdriver_bidi_websocket_mask_key::WebDriverBiDiWebSocketMaskKey; From cbf3dcce94c2906edc2971a3804a366a5818f60c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:33:39 -0700 Subject: [PATCH 497/570] fix(network): route mask entropy through redacted public type --- .../src/webdriver_bidi_websocket_validated.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index 32ae2b90c..f4d23a63f 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -11,6 +11,7 @@ use originweave_core::VerifiedWebDriverBiDiSocketPeer; use crate::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence, webdriver_bidi_websocket_handshake_raw as raw, + webdriver_bidi_websocket_mask_key::WebDriverBiDiWebSocketMaskKey, }; const MAX_TRACKED_CLIENT_MASK_KEYS: usize = 65_536; @@ -27,7 +28,7 @@ struct ClientMaskKeyHistory { impl ClientMaskKeyHistory { fn reserve( &mut self, - masking_key: raw::WebDriverBiDiWebSocketMaskKey, + masking_key: WebDriverBiDiWebSocketMaskKey, ) -> Result<(), raw::WebDriverBiDiWebSocketFrameError> { let masking_key = *masking_key.as_bytes(); if self.used_keys.contains(&masking_key) { @@ -213,17 +214,18 @@ impl WebDriverBiDiWebSocketEstablished { /// The caller-supplied masking key is reserved before any frame bytes are emitted. Reuse of any /// key previously used by a successful client text or Pong frame on this established connection /// fails closed. The exact history is bounded; reaching the reviewed history ceiling also fails - /// closed rather than silently forgetting older keys. + /// closed rather than silently forgetting older keys. Generic diagnostics for the public mask-key + /// value redact its entropy; only this reviewed wire-framing boundary unwraps the exact bytes. pub fn write_text_frame( mut self, text: &str, - masking_key: raw::WebDriverBiDiWebSocketMaskKey, + masking_key: WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { self.client_mask_keys.reserve(masking_key)?; self.raw = self .raw - .write_text_frame(text, masking_key, frame_timeout)?; + .write_text_frame(text, masking_key.into_raw(), frame_timeout)?; Ok(self) } @@ -234,7 +236,7 @@ impl WebDriverBiDiWebSocketEstablished { pub fn write_pong_frame( mut self, payload: &[u8], - masking_key: raw::WebDriverBiDiWebSocketMaskKey, + masking_key: WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { self.client_mask_keys.reserve(masking_key)?; @@ -279,9 +281,9 @@ mod tests { #[test] fn client_mask_history_rejects_reuse_and_fails_closed_at_its_bound() { - let first = raw::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); - let second = raw::WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]); - let third = raw::WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); + let first = WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let second = WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]); + let third = WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); let mut history = ClientMaskKeyHistory::<2>::default(); assert!(history.reserve(first).is_ok()); From 400ce7bec2bd649fe0566d5d86745dbbc5026770 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:36:58 -0700 Subject: [PATCH 498/570] fix(network): preserve redacted WebSocket mask-key boundary --- .../src/webdriver_bidi_websocket_mask_key.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 crates/originweave-network/src/webdriver_bidi_websocket_mask_key.rs diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_mask_key.rs b/crates/originweave-network/src/webdriver_bidi_websocket_mask_key.rs new file mode 100644 index 000000000..8f0f437d1 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_mask_key.rs @@ -0,0 +1,36 @@ +use std::fmt; + +use crate::webdriver_bidi_websocket_handshake_raw as raw; + +/// Caller-supplied RFC 6455 mask key for one client-to-server frame. +/// +/// RFC 6455 requires every client frame to carry a fresh, unpredictable four-byte key. This +/// public wrapper keeps those bytes available only to the framing boundary while ensuring generic +/// diagnostics cannot render the masking entropy. Callers remain responsible for obtaining a fresh +/// key from an approved randomness source for every client frame. +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketMaskKey(raw::WebDriverBiDiWebSocketMaskKey); + +impl fmt::Debug for WebDriverBiDiWebSocketMaskKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("") + } +} + +impl WebDriverBiDiWebSocketMaskKey { + /// Admit one four-byte caller-supplied frame masking key. + #[must_use] + pub const fn new(value: [u8; 4]) -> Self { + Self(raw::WebDriverBiDiWebSocketMaskKey::new(value)) + } + + /// Borrow the exact four-byte key for the reviewed wire-framing boundary. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 4] { + self.0.as_bytes() + } + + pub(crate) const fn into_raw(self) -> raw::WebDriverBiDiWebSocketMaskKey { + self.0 + } +} From f10670cb287a564982fb69f4ed052f94ab20fb30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:37:21 -0700 Subject: [PATCH 499/570] fix(network): preserve redacted mask-key export in locateNodes stack --- crates/originweave-network/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 3d2aac226..59902999d 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -32,6 +32,7 @@ mod webdriver_bidi_websocket_debug_tests; mod webdriver_bidi_websocket_handshake; #[path = "webdriver_bidi_websocket_raw_redacted.rs"] mod webdriver_bidi_websocket_handshake_raw; +mod webdriver_bidi_websocket_mask_key; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, @@ -55,5 +56,6 @@ pub use webdriver_bidi_websocket_handshake_raw::{ MAX_WEBSOCKET_OPENING_WRITE_TIMEOUT, WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketFrame, WebDriverBiDiWebSocketFrameError, WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakeResponseError, - WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketOpeningWriteError, + WebDriverBiDiWebSocketOpeningWriteError, }; +pub use webdriver_bidi_websocket_mask_key::WebDriverBiDiWebSocketMaskKey; From 40335f184fc60cdfaf8e5bbaef70bed3c1998716 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:38:08 -0700 Subject: [PATCH 500/570] fix(network): preserve redacted mask-key transport in locateNodes stack --- .../src/webdriver_bidi_websocket_validated.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index e16acb59c..0b913b635 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -11,6 +11,7 @@ use originweave_core::VerifiedWebDriverBiDiSocketPeer; use crate::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionEvidence, webdriver_bidi_websocket_handshake_raw as raw, + webdriver_bidi_websocket_mask_key::WebDriverBiDiWebSocketMaskKey, }; const MAX_TRACKED_CLIENT_MASK_KEYS: usize = 65_536; @@ -27,7 +28,7 @@ struct ClientMaskKeyHistory { impl ClientMaskKeyHistory { fn reserve( &mut self, - masking_key: raw::WebDriverBiDiWebSocketMaskKey, + masking_key: WebDriverBiDiWebSocketMaskKey, ) -> Result<(), raw::WebDriverBiDiWebSocketFrameError> { let masking_key = *masking_key.as_bytes(); if self.used_keys.contains(&masking_key) { @@ -219,17 +220,18 @@ impl WebDriverBiDiWebSocketEstablished { /// The caller-supplied masking key is reserved before any frame bytes are emitted. Reuse of any /// key previously used by a successful client text or Pong frame on this established connection /// fails closed. The exact history is bounded; reaching the reviewed history ceiling also fails - /// closed rather than silently forgetting older keys. + /// closed rather than silently forgetting older keys. Generic diagnostics for the public mask-key + /// value redact its entropy; only this reviewed wire-framing boundary unwraps the exact bytes. pub fn write_text_frame( mut self, text: &str, - masking_key: raw::WebDriverBiDiWebSocketMaskKey, + masking_key: WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { self.client_mask_keys.reserve(masking_key)?; self.raw = self .raw - .write_text_frame(text, masking_key, frame_timeout)?; + .write_text_frame(text, masking_key.into_raw(), frame_timeout)?; Ok(self) } @@ -240,7 +242,7 @@ impl WebDriverBiDiWebSocketEstablished { pub fn write_pong_frame( mut self, payload: &[u8], - masking_key: raw::WebDriverBiDiWebSocketMaskKey, + masking_key: WebDriverBiDiWebSocketMaskKey, frame_timeout: Duration, ) -> Result { self.client_mask_keys.reserve(masking_key)?; @@ -285,9 +287,9 @@ mod tests { #[test] fn client_mask_history_rejects_reuse_and_fails_closed_at_its_bound() { - let first = raw::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); - let second = raw::WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]); - let third = raw::WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); + let first = WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); + let second = WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]); + let third = WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); let mut history = ClientMaskKeyHistory::<2>::default(); assert!(history.reserve(first).is_ok()); From f94b2ecf821815892d8870410023ed6fa3e7ddae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 05:38:36 -0700 Subject: [PATCH 501/570] test(network): preserve mask-key redaction regression in locateNodes stack --- .../src/webdriver_bidi_websocket_debug_tests.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs index 3ed8b9061..0f8bb957e 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_debug_tests.rs @@ -4,7 +4,7 @@ use originweave_core::WebDriverBiDiWebSocketEndpoint; use crate::{ WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, - WebDriverBiDiWebSocketHandshakePlan, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, webdriver_bidi_websocket_handshake_raw::WebDriverBiDiWebSocketHandshakePlan as RawWebDriverBiDiWebSocketHandshakePlan, }; @@ -21,6 +21,18 @@ fn client_key_debug_redacts_client_nonce() { assert!(!debug.contains(CLIENT_KEY)); } +#[test] +fn masking_key_debug_redacts_frame_entropy() { + let masking_key = WebDriverBiDiWebSocketMaskKey::new([17, 34, 51, 68]); + + let debug = format!("{masking_key:?}"); + assert!(debug.contains("")); + assert!(!debug.contains("17")); + assert!(!debug.contains("34")); + assert!(!debug.contains("51")); + assert!(!debug.contains("68")); +} + #[test] fn raw_handshake_plan_debug_omits_serialized_request() { let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); From b7e02876e57dfed0302a89cab9539cc272f9010b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 06:13:32 -0700 Subject: [PATCH 502/570] test(network): prove legacy handshake debug redacts nonce --- .../webdriver_bidi_websocket_raw_redacted.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs index 91c683339..16d5fe0da 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs @@ -78,3 +78,56 @@ impl WebDriverBiDiWebSocketHandshakePlan { self.0.write_opening_request(write_timeout) } } + +#[cfg(test)] +mod tests { + use std::{net::TcpListener, thread}; + + use originweave_core::WebDriverBiDiWebSocketEndpoint; + + use super::*; + use crate::WebDriverBiDiTcpConnectionPlan; + + const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + const CLIENT_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + + #[test] + fn legacy_handshake_plan_debug_redacts_serialized_client_nonce() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); + let address = listener + .local_addr() + .expect("test listener address must be available"); + let server = thread::spawn(move || { + listener + .accept() + .map(|_| ()) + .expect("test loopback connection must be accepted"); + }); + + let endpoint = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}")) + .expect("test endpoint must be valid"); + let correlated = endpoint + .correlate_session_id(SESSION_ID) + .expect("test session must correlate"); + let target = correlated + .into_explicit_connect_target() + .expect("test target must be explicit"); + let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) + .expect("test connection plan must be valid") + .connect() + .expect("test connection must succeed"); + let client_key = + WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY).expect("test client key must be valid"); + let handshake = legacy::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key) + .expect("test legacy handshake plan must be valid"); + + let debug = format!("{handshake:?}"); + assert!(debug.contains("")); + assert!(!debug.contains("request: [")); + assert!(!debug.contains(CLIENT_KEY)); + + drop(handshake); + server.join().expect("test server must not panic"); + } +} From 01e6ae499762026f5681eb1e3e24f831e03409e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 06:15:29 -0700 Subject: [PATCH 503/570] test(network): apply canonical nonce-redaction regression formatting --- .../src/webdriver_bidi_websocket_raw_redacted.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs index 16d5fe0da..2807e56db 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs @@ -117,8 +117,8 @@ mod tests { .expect("test connection plan must be valid") .connect() .expect("test connection must succeed"); - let client_key = - WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY).expect("test client key must be valid"); + let client_key = WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY) + .expect("test client key must be valid"); let handshake = legacy::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key) .expect("test legacy handshake plan must be valid"); From 9a6ccf8503bd3c7238d2b0e1606981bc188d9ce3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 06:37:34 -0700 Subject: [PATCH 504/570] fix(network): redact legacy websocket handshake debug --- .../src/webdriver_bidi_websocket_handshake.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 92524564e..5c349ba19 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -144,13 +144,23 @@ impl WebDriverBiDiWebSocketMaskKey { /// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` /// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or /// Agent-authority grant. -#[derive(Debug)] pub struct WebDriverBiDiWebSocketHandshakePlan { connection: WebDriverBiDiTcpConnection, client_key: WebDriverBiDiWebSocketClientKey, request: Vec, } +impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketHandshakePlan") + .field("verified_peer", self.connection.verified_peer()) + .field("client_key", &"") + .field("request_byte_count", &self.request.len()) + .finish() + } +} + impl WebDriverBiDiWebSocketHandshakePlan { /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. pub fn new( From 34f9065628befc50b29c87a9ff7225feb126f404 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 06:45:49 -0700 Subject: [PATCH 505/570] test(network): reproduce masking history lifetime ceiling --- ..._websocket_masking_key_history_capacity.rs | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs new file mode 100644 index 000000000..b9aaef346 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs @@ -0,0 +1,102 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const FRAME_COUNT: u32 = 65_537; + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_one_masked_single_byte_text_frame(stream: &mut TcpStream) -> io::Result<()> { + let mut frame = [0_u8; 7]; + stream.read_exact(&mut frame)?; + if frame[0] != 0x81 || frame[1] != 0x81 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client did not send one final masked single-byte text frame", + )); + } + if frame[6] ^ frame[2] != b'x' { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "masked text payload did not decode to the expected byte", + )); + } + Ok(()) +} + +#[test] +fn established_stream_does_not_gain_a_lifetime_frame_cap_from_reuse_detection() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + for _ in 0..FRAME_COUNT { + read_one_masked_single_byte_text_frame(&mut stream)?; + } + Ok(FRAME_COUNT) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let mut established = written.read_opening_response(Duration::from_millis(500))?; + + for ordinal in 0..FRAME_COUNT { + let masking_key = WebDriverBiDiWebSocketMaskKey::new((ordinal + 1).to_be_bytes()); + established = established.write_text_frame( + "x", + masking_key, + Duration::from_millis(500), + )?; + } + drop(established); + + let received = server + .join() + .map_err(|_| io::Error::other("WebSocket history-cap test server panicked"))??; + assert_eq!(received, FRAME_COUNT); + Ok(()) +} From d98f97c73f8d73482d641ff08d5153b58dab2289 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:05:58 -0700 Subject: [PATCH 506/570] test(network): format masking-key capacity regression --- ...webdriver_bidi_websocket_masking_key_history_capacity.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs index b9aaef346..8f42acb7c 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs @@ -86,11 +86,7 @@ fn established_stream_does_not_gain_a_lifetime_frame_cap_from_reuse_detection() for ordinal in 0..FRAME_COUNT { let masking_key = WebDriverBiDiWebSocketMaskKey::new((ordinal + 1).to_be_bytes()); - established = established.write_text_frame( - "x", - masking_key, - Duration::from_millis(500), - )?; + established = established.write_text_frame("x", masking_key, Duration::from_millis(500))?; } drop(established); From 608abfe58add537df61104eff86dc20f780ef6bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:09:58 -0700 Subject: [PATCH 507/570] fix(network): remove WebSocket masking-key lifetime cap --- .../src/webdriver_bidi_websocket_validated.rs | 65 +++++++------------ 1 file changed, 24 insertions(+), 41 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index f4d23a63f..e9039c89e 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -4,7 +4,7 @@ //! public state machine while adding protocol validation that must run before a received frame is //! released to callers. -use std::{collections::BTreeSet, fmt, time::Duration}; +use std::{fmt, time::Duration}; use originweave_core::VerifiedWebDriverBiDiSocketPeer; @@ -14,34 +14,26 @@ use crate::{ webdriver_bidi_websocket_mask_key::WebDriverBiDiWebSocketMaskKey, }; -const MAX_TRACKED_CLIENT_MASK_KEYS: usize = 65_536; const REUSED_CLIENT_MASK_KEY_REASON: &str = - "client masking key was already used on this established WebSocket"; -const CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON: &str = - "client masking-key history reached its reviewed per-connection bound"; + "client masking key was reused for consecutive frames on this established WebSocket"; #[derive(Default)] -struct ClientMaskKeyHistory { - used_keys: BTreeSet<[u8; 4]>, +struct ClientMaskKeyHistory { + previous_key: Option<[u8; 4]>, } -impl ClientMaskKeyHistory { +impl ClientMaskKeyHistory { fn reserve( &mut self, masking_key: WebDriverBiDiWebSocketMaskKey, ) -> Result<(), raw::WebDriverBiDiWebSocketFrameError> { let masking_key = *masking_key.as_bytes(); - if self.used_keys.contains(&masking_key) { + if self.previous_key == Some(masking_key) { return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: REUSED_CLIENT_MASK_KEY_REASON, }); } - if self.used_keys.len() >= LIMIT { - return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON, - }); - } - self.used_keys.insert(masking_key); + self.previous_key = Some(masking_key); Ok(()) } } @@ -152,12 +144,14 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { /// A live verified stream after both RFC 6455 opening messages were validated. /// -/// Successful outbound client frames retain a bounded exact history of their RFC 6455 masking keys -/// so the same four-byte key cannot be emitted twice on one established connection. The history is -/// capped at 65,536 keys; exhausting that bound fails closed before another client frame is written. +/// The caller remains responsible for deriving every RFC 6455 masking key from a strong source of +/// entropy. OriginWeave additionally rejects immediate key repetition across adjacent client text or +/// Pong frames as a bounded defense against a stuck or accidentally reused caller value. It does not +/// impose global key uniqueness, because RFC 6455 requires fresh unpredictable selection rather than +/// collision-free values and a 32-bit random key can legitimately recur over a long-lived session. pub struct WebDriverBiDiWebSocketEstablished { raw: raw::WebDriverBiDiWebSocketEstablished, - client_mask_keys: ClientMaskKeyHistory, + client_mask_keys: ClientMaskKeyHistory, } impl fmt::Debug for WebDriverBiDiWebSocketEstablished { @@ -211,11 +205,12 @@ impl WebDriverBiDiWebSocketEstablished { /// Write one unfragmented, masked UTF-8 text frame on this verified stream. /// - /// The caller-supplied masking key is reserved before any frame bytes are emitted. Reuse of any - /// key previously used by a successful client text or Pong frame on this established connection - /// fails closed. The exact history is bounded; reaching the reviewed history ceiling also fails - /// closed rather than silently forgetting older keys. Generic diagnostics for the public mask-key - /// value redact its entropy; only this reviewed wire-framing boundary unwraps the exact bytes. + /// The caller-supplied masking key must come from an approved strong randomness source. The + /// immediately preceding successful client text or Pong key is retained so accidental adjacent + /// reuse fails closed before any frame bytes are emitted, without treating random collisions + /// across the entire connection lifetime as protocol failures. Generic diagnostics for the + /// public mask-key value redact its entropy; only this reviewed wire-framing boundary unwraps the + /// exact bytes. pub fn write_text_frame( mut self, text: &str, @@ -231,8 +226,8 @@ impl WebDriverBiDiWebSocketEstablished { /// Write one final masked RFC 6455 Pong control frame on this verified stream. /// - /// Masking-key reuse is rejected against the same bounded history used by text frames so - /// switching frame types cannot bypass the RFC 6455 freshness boundary. + /// Immediate masking-key reuse is rejected against the same previous-frame guard used by text + /// frames, so switching frame types cannot bypass detection of a stuck caller key. pub fn write_pong_frame( mut self, payload: &[u8], @@ -280,11 +275,10 @@ mod tests { use super::*; #[test] - fn client_mask_history_rejects_reuse_and_fails_closed_at_its_bound() { + fn client_mask_history_rejects_only_immediate_reuse_without_a_lifetime_cap() { let first = WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); let second = WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]); - let third = WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); - let mut history = ClientMaskKeyHistory::<2>::default(); + let mut history = ClientMaskKeyHistory::default(); assert!(history.reserve(first).is_ok()); assert!(matches!( @@ -294,17 +288,6 @@ mod tests { }) )); assert!(history.reserve(second).is_ok()); - assert!(matches!( - history.reserve(third), - Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON - }) - )); - assert!(matches!( - history.reserve(first), - Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: REUSED_CLIENT_MASK_KEY_REASON - }) - )); + assert!(history.reserve(first).is_ok()); } } From e8e7eff976f0c34627af1930b055a36b253c5782 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:10:36 -0700 Subject: [PATCH 508/570] test(network): align consecutive mask-reuse evidence --- .../tests/webdriver_bidi_websocket_masking_key_reuse.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs index 82f9472dc..4f36bff23 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -16,7 +16,7 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const REUSED_MASK_REASON: &str = - "client masking key was already used on this established WebSocket"; + "client masking key was reused for consecutive frames on this established WebSocket"; fn connect( endpoint: &str, From 245ae04a34e81e24e4fd30099e2eacb9d82628a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:12:34 -0700 Subject: [PATCH 509/570] test(network): exercise non-global mask reuse on wire --- .../webdriver_bidi_websocket_masking_key_history_capacity.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs index 8f42acb7c..509d303a9 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs @@ -85,7 +85,8 @@ fn established_stream_does_not_gain_a_lifetime_frame_cap_from_reuse_detection() let mut established = written.read_opening_response(Duration::from_millis(500))?; for ordinal in 0..FRAME_COUNT { - let masking_key = WebDriverBiDiWebSocketMaskKey::new((ordinal + 1).to_be_bytes()); + let key_ordinal = (ordinal % (FRAME_COUNT - 1)) + 1; + let masking_key = WebDriverBiDiWebSocketMaskKey::new(key_ordinal.to_be_bytes()); established = established.write_text_frame("x", masking_key, Duration::from_millis(500))?; } drop(established); From a77e1ce18c615a2ed4ec5234b6541a8369581309 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:18:18 -0700 Subject: [PATCH 510/570] test(network): align Pong mask-reuse regression --- .../tests/webdriver_bidi_websocket_pong_write.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs index 1dff3681a..5fdd81c02 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs @@ -16,7 +16,7 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const REUSED_MASK_REASON: &str = - "client masking key was already used on this established WebSocket"; + "client masking key was reused for consecutive frames on this established WebSocket"; const MAX_PONG_PAYLOAD_BYTES: usize = 125; fn connect( From b47c4fdde02b9c2cdcdc3ccf25076e29c37b2feb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:21:32 -0700 Subject: [PATCH 511/570] test(network): satisfy strict Clippy in redaction regression --- .../webdriver_bidi_websocket_raw_redacted.rs | 45 +++++++------------ 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs index 2807e56db..5f940f559 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs @@ -81,7 +81,7 @@ impl WebDriverBiDiWebSocketHandshakePlan { #[cfg(test)] mod tests { - use std::{net::TcpListener, thread}; + use std::{error::Error, io, net::TcpListener, thread}; use originweave_core::WebDriverBiDiWebSocketEndpoint; @@ -92,35 +92,19 @@ mod tests { const CLIENT_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; #[test] - fn legacy_handshake_plan_debug_redacts_serialized_client_nonce() { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener must bind"); - let address = listener - .local_addr() - .expect("test listener address must be available"); - let server = thread::spawn(move || { - listener - .accept() - .map(|_| ()) - .expect("test loopback connection must be accepted"); - }); + fn legacy_handshake_plan_debug_redacts_serialized_client_nonce() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let address = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { listener.accept().map(|_| ()) }); let endpoint = - WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}")) - .expect("test endpoint must be valid"); - let correlated = endpoint - .correlate_session_id(SESSION_ID) - .expect("test session must correlate"); - let target = correlated - .into_explicit_connect_target() - .expect("test target must be explicit"); - let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1) - .expect("test connection plan must be valid") - .connect() - .expect("test connection must succeed"); - let client_key = WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY) - .expect("test client key must be valid"); - let handshake = legacy::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key) - .expect("test legacy handshake plan must be valid"); + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}"))?; + let correlated = endpoint.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)? + .connect()?; + let client_key = WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY)?; + let handshake = legacy::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key)?; let debug = format!("{handshake:?}"); assert!(debug.contains("")); @@ -128,6 +112,9 @@ mod tests { assert!(!debug.contains(CLIENT_KEY)); drop(handshake); - server.join().expect("test server must not panic"); + server + .join() + .map_err(|_| io::Error::other("test WebSocket debug server panicked"))??; + Ok(()) } } From 9e550a4563c1025c944898b943cc6607f2ffd46f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 07:23:56 -0700 Subject: [PATCH 512/570] style(network): apply canonical Rust formatting --- .../src/webdriver_bidi_websocket_raw_redacted.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs index 5f940f559..d25606bc8 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs @@ -101,8 +101,8 @@ mod tests { WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}"))?; let correlated = endpoint.correlate_session_id(SESSION_ID)?; let target = correlated.into_explicit_connect_target()?; - let connection = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)? - .connect()?; + let connection = + WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; let client_key = WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY)?; let handshake = legacy::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key)?; From c1bc7e78f3a9debf4f517fb6b5f11dd67be4ad92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 08:09:24 -0700 Subject: [PATCH 513/570] test(network): remove duplicate nonce-redaction unit --- .../webdriver_bidi_websocket_raw_redacted.rs | 40 ------------------- 1 file changed, 40 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs index d25606bc8..91c683339 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_raw_redacted.rs @@ -78,43 +78,3 @@ impl WebDriverBiDiWebSocketHandshakePlan { self.0.write_opening_request(write_timeout) } } - -#[cfg(test)] -mod tests { - use std::{error::Error, io, net::TcpListener, thread}; - - use originweave_core::WebDriverBiDiWebSocketEndpoint; - - use super::*; - use crate::WebDriverBiDiTcpConnectionPlan; - - const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; - const CLIENT_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; - - #[test] - fn legacy_handshake_plan_debug_redacts_serialized_client_nonce() -> Result<(), Box> { - let listener = TcpListener::bind(("127.0.0.1", 0))?; - let address = listener.local_addr()?; - let server = thread::spawn(move || -> io::Result<()> { listener.accept().map(|_| ()) }); - - let endpoint = - WebDriverBiDiWebSocketEndpoint::new(&format!("ws://{address}/session/{SESSION_ID}"))?; - let correlated = endpoint.correlate_session_id(SESSION_ID)?; - let target = correlated.into_explicit_connect_target()?; - let connection = - WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?.connect()?; - let client_key = WebDriverBiDiWebSocketClientKey::new(CLIENT_KEY)?; - let handshake = legacy::WebDriverBiDiWebSocketHandshakePlan::new(connection, client_key)?; - - let debug = format!("{handshake:?}"); - assert!(debug.contains("")); - assert!(!debug.contains("request: [")); - assert!(!debug.contains(CLIENT_KEY)); - - drop(handshake); - server - .join() - .map_err(|_| io::Error::other("test WebSocket debug server panicked"))??; - Ok(()) - } -} From b68e0f6f3b41cc147410fb6eef1050f286bacc6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 08:43:16 -0700 Subject: [PATCH 514/570] fix(network): preserve current frame-transport parent --- .../src/webdriver_bidi_websocket_handshake.rs | 12 ++- .../src/webdriver_bidi_websocket_validated.rs | 65 +++++------- ..._websocket_masking_key_history_capacity.rs | 99 +++++++++++++++++++ ...driver_bidi_websocket_masking_key_reuse.rs | 2 +- .../webdriver_bidi_websocket_pong_write.rs | 2 +- 5 files changed, 136 insertions(+), 44 deletions(-) create mode 100644 crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 92524564e..5c349ba19 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -144,13 +144,23 @@ impl WebDriverBiDiWebSocketMaskKey { /// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` /// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or /// Agent-authority grant. -#[derive(Debug)] pub struct WebDriverBiDiWebSocketHandshakePlan { connection: WebDriverBiDiTcpConnection, client_key: WebDriverBiDiWebSocketClientKey, request: Vec, } +impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketHandshakePlan") + .field("verified_peer", self.connection.verified_peer()) + .field("client_key", &"") + .field("request_byte_count", &self.request.len()) + .finish() + } +} + impl WebDriverBiDiWebSocketHandshakePlan { /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. pub fn new( diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs index 0b913b635..25f76a566 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_validated.rs @@ -4,7 +4,7 @@ //! public state machine while adding protocol validation that must run before a received frame is //! released to callers. -use std::{collections::BTreeSet, fmt, time::Duration}; +use std::{fmt, time::Duration}; use originweave_core::VerifiedWebDriverBiDiSocketPeer; @@ -14,34 +14,26 @@ use crate::{ webdriver_bidi_websocket_mask_key::WebDriverBiDiWebSocketMaskKey, }; -const MAX_TRACKED_CLIENT_MASK_KEYS: usize = 65_536; const REUSED_CLIENT_MASK_KEY_REASON: &str = - "client masking key was already used on this established WebSocket"; -const CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON: &str = - "client masking-key history reached its reviewed per-connection bound"; + "client masking key was reused for consecutive frames on this established WebSocket"; #[derive(Default)] -struct ClientMaskKeyHistory { - used_keys: BTreeSet<[u8; 4]>, +struct ClientMaskKeyHistory { + previous_key: Option<[u8; 4]>, } -impl ClientMaskKeyHistory { +impl ClientMaskKeyHistory { fn reserve( &mut self, masking_key: WebDriverBiDiWebSocketMaskKey, ) -> Result<(), raw::WebDriverBiDiWebSocketFrameError> { let masking_key = *masking_key.as_bytes(); - if self.used_keys.contains(&masking_key) { + if self.previous_key == Some(masking_key) { return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { reason: REUSED_CLIENT_MASK_KEY_REASON, }); } - if self.used_keys.len() >= LIMIT { - return Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON, - }); - } - self.used_keys.insert(masking_key); + self.previous_key = Some(masking_key); Ok(()) } } @@ -152,12 +144,14 @@ impl WebDriverBiDiWebSocketOpeningRequestSent { /// A live verified stream after both RFC 6455 opening messages were validated. /// -/// Successful outbound client frames retain a bounded exact history of their RFC 6455 masking keys -/// so the same four-byte key cannot be emitted twice on one established connection. The history is -/// capped at 65,536 keys; exhausting that bound fails closed before another client frame is written. +/// The caller remains responsible for deriving every RFC 6455 masking key from a strong source of +/// entropy. OriginWeave additionally rejects immediate key repetition across adjacent client text or +/// Pong frames as a bounded defense against a stuck or accidentally reused caller value. It does not +/// impose global key uniqueness, because RFC 6455 requires fresh unpredictable selection rather than +/// collision-free values and a 32-bit random key can legitimately recur over a long-lived session. pub struct WebDriverBiDiWebSocketEstablished { raw: raw::WebDriverBiDiWebSocketEstablished, - client_mask_keys: ClientMaskKeyHistory, + client_mask_keys: ClientMaskKeyHistory, } impl fmt::Debug for WebDriverBiDiWebSocketEstablished { @@ -217,11 +211,12 @@ impl WebDriverBiDiWebSocketEstablished { /// Write one unfragmented, masked UTF-8 text frame on this verified stream. /// - /// The caller-supplied masking key is reserved before any frame bytes are emitted. Reuse of any - /// key previously used by a successful client text or Pong frame on this established connection - /// fails closed. The exact history is bounded; reaching the reviewed history ceiling also fails - /// closed rather than silently forgetting older keys. Generic diagnostics for the public mask-key - /// value redact its entropy; only this reviewed wire-framing boundary unwraps the exact bytes. + /// The caller-supplied masking key must come from an approved strong randomness source. The + /// immediately preceding successful client text or Pong key is retained so accidental adjacent + /// reuse fails closed before any frame bytes are emitted, without treating random collisions + /// across the entire connection lifetime as protocol failures. Generic diagnostics for the + /// public mask-key value redact its entropy; only this reviewed wire-framing boundary unwraps the + /// exact bytes. pub fn write_text_frame( mut self, text: &str, @@ -237,8 +232,8 @@ impl WebDriverBiDiWebSocketEstablished { /// Write one final masked RFC 6455 Pong control frame on this verified stream. /// - /// Masking-key reuse is rejected against the same bounded history used by text frames so - /// switching frame types cannot bypass the RFC 6455 freshness boundary. + /// Immediate masking-key reuse is rejected against the same previous-frame guard used by text + /// frames, so switching frame types cannot bypass detection of a stuck caller key. pub fn write_pong_frame( mut self, payload: &[u8], @@ -286,11 +281,10 @@ mod tests { use super::*; #[test] - fn client_mask_history_rejects_reuse_and_fails_closed_at_its_bound() { + fn client_mask_history_rejects_only_immediate_reuse_without_a_lifetime_cap() { let first = WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); let second = WebDriverBiDiWebSocketMaskKey::new([5, 6, 7, 8]); - let third = WebDriverBiDiWebSocketMaskKey::new([9, 10, 11, 12]); - let mut history = ClientMaskKeyHistory::<2>::default(); + let mut history = ClientMaskKeyHistory::default(); assert!(history.reserve(first).is_ok()); assert!(matches!( @@ -300,17 +294,6 @@ mod tests { }) )); assert!(history.reserve(second).is_ok()); - assert!(matches!( - history.reserve(third), - Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: CLIENT_MASK_KEY_HISTORY_EXHAUSTED_REASON - }) - )); - assert!(matches!( - history.reserve(first), - Err(raw::WebDriverBiDiWebSocketFrameError::MalformedFrame { - reason: REUSED_CLIENT_MASK_KEY_REASON - }) - )); + assert!(history.reserve(first).is_ok()); } } diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs new file mode 100644 index 000000000..509d303a9 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_history_capacity.rs @@ -0,0 +1,99 @@ +use std::{ + error::Error, + io::{self, Read, Write}, + net::{TcpListener, TcpStream}, + thread, + time::Duration, +}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakePlan, WebDriverBiDiWebSocketMaskKey, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const FRAME_COUNT: u32 = 65_537; + +fn connect( + endpoint: &str, +) -> Result> { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint)?; + let correlated = admitted.correlate_session_id(SESSION_ID)?; + let target = correlated.into_explicit_connect_target()?; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1)?; + Ok(plan.connect()?) +} + +fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + while !request.ends_with(b"\r\n\r\n") { + let count = stream.read(&mut buffer)?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "client opening request ended before the header terminator", + )); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(()) +} + +fn read_one_masked_single_byte_text_frame(stream: &mut TcpStream) -> io::Result<()> { + let mut frame = [0_u8; 7]; + stream.read_exact(&mut frame)?; + if frame[0] != 0x81 || frame[1] != 0x81 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "client did not send one final masked single-byte text frame", + )); + } + if frame[6] ^ frame[2] != b'x' { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "masked text payload did not decode to the expected byte", + )); + } + Ok(()) +} + +#[test] +fn established_stream_does_not_gain_a_lifetime_frame_cap_from_reuse_detection() +-> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + for _ in 0..FRAME_COUNT { + read_one_masked_single_byte_text_frame(&mut stream)?; + } + Ok(FRAME_COUNT) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let mut established = written.read_opening_response(Duration::from_millis(500))?; + + for ordinal in 0..FRAME_COUNT { + let key_ordinal = (ordinal % (FRAME_COUNT - 1)) + 1; + let masking_key = WebDriverBiDiWebSocketMaskKey::new(key_ordinal.to_be_bytes()); + established = established.write_text_frame("x", masking_key, Duration::from_millis(500))?; + } + drop(established); + + let received = server + .join() + .map_err(|_| io::Error::other("WebSocket history-cap test server panicked"))??; + assert_eq!(received, FRAME_COUNT); + Ok(()) +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs index 82f9472dc..4f36bff23 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -16,7 +16,7 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const REUSED_MASK_REASON: &str = - "client masking key was already used on this established WebSocket"; + "client masking key was reused for consecutive frames on this established WebSocket"; fn connect( endpoint: &str, diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs index 1dff3681a..5fdd81c02 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_pong_write.rs @@ -16,7 +16,7 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const REUSED_MASK_REASON: &str = - "client masking key was already used on this established WebSocket"; + "client masking key was reused for consecutive frames on this established WebSocket"; const MAX_PONG_PAYLOAD_BYTES: usize = 125; fn connect( From 9c165ccfb5d45464c16b2e9de7098f49f38210da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 08:47:37 -0700 Subject: [PATCH 515/570] fix(network): bound Pong entropy before deadline --- .../webdriver_bidi_locate_nodes_exchange.rs | 74 +++++++++++++++---- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 358e7ff56..14b615464 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -161,6 +161,15 @@ fn next_pong_masking_key( next_key().ok_or(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyUnavailable) } +fn next_pong_masking_key_before_deadline( + next_key: &mut dyn FnMut() -> Option, + exchange_timeout: Duration, + elapsed: Duration, +) -> Result { + remaining_frame_operation_budget(exchange_timeout, elapsed)?; + next_pong_masking_key(next_key) +} + fn map_established_frame_result( result: Result, ) -> Result { @@ -212,10 +221,12 @@ impl WebDriverBiDiWebSocketEstablished { /// text frame using `command_masking_key`. Valid server Ping frames are answered with a masked /// Pong carrying the exact Ping application data, while unsolicited valid Pong frames are /// consumed without changing BiDi state. Each Ping obtains a caller-supplied client mask from - /// `next_pong_key`; exhausting that caller-owned entropy source or repeating any key already used - /// by the command or a prior Pong fails closed before another client frame is emitted. The caller - /// remains responsible for generating each supplied key from a strong unpredictable entropy - /// source; exact non-reuse checks do not prove cryptographic unpredictability. + /// `next_pong_key` only after a positive remaining-budget check; exhausting that caller-owned + /// entropy source or repeating any key already used by the command or a prior Pong fails closed + /// before another client frame is emitted. Callback time is charged by a second deadline check + /// before the Pong write. The caller remains responsible for generating each supplied key from a + /// strong unpredictable entropy source; exact non-reuse checks do not prove cryptographic + /// unpredictability. /// /// RFC 6455 text-message fragmentation is reassembled only for one response message at a time. /// A non-final text frame starts that message, continuation frames extend it in order, and a final @@ -264,9 +275,7 @@ impl WebDriverBiDiWebSocketEstablished { write_timeout, ))?; let mut control_frame_count = 0_usize; - let mut used_client_masking_keys = - [command_masking_key; MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE + 1]; - let mut used_client_masking_key_count = 1_usize; + let mut used_client_masking_keys = vec![command_masking_key]; let mut response_fragment_count = 0_usize; let mut response_message = Vec::new(); let mut assembling_text_response = false; @@ -293,14 +302,15 @@ impl WebDriverBiDiWebSocketEstablished { match opcode { 0x9 => { - let masking_key = next_pong_masking_key(next_pong_key)?; - if used_client_masking_keys[..used_client_masking_key_count] - .contains(&masking_key) - { + let masking_key = next_pong_masking_key_before_deadline( + next_pong_key, + exchange_timeout, + started_at.elapsed(), + )?; + if used_client_masking_keys.contains(&masking_key) { return Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyReused); } - used_client_masking_keys[used_client_masking_key_count] = masking_key; - used_client_masking_key_count += 1; + used_client_masking_keys.push(masking_key); let remaining_timeout = remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; established = map_established_frame_result(established.write_pong_frame( @@ -399,8 +409,8 @@ mod tests { use super::{ MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, - append_response_fragment, next_pong_masking_key, remaining_exchange_budget, - remaining_frame_operation_budget, + append_response_fragment, next_pong_masking_key, next_pong_masking_key_before_deadline, + remaining_exchange_budget, remaining_frame_operation_budget, }; #[test] @@ -468,6 +478,40 @@ mod tests { ); } + #[test] + fn pong_entropy_is_not_drawn_after_exchange_deadline() { + let expected = crate::WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); + let mut draw_count = 0_usize; + { + let mut next = || { + draw_count += 1; + Some(expected) + }; + assert!(matches!( + next_pong_masking_key_before_deadline( + &mut next, + Duration::from_millis(500), + Duration::from_millis(500), + ), + Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + }) if exchange_timeout == Duration::from_millis(500) + )); + } + assert_eq!(draw_count, 0); + + let mut available = || Some(expected); + assert_eq!( + next_pong_masking_key_before_deadline( + &mut available, + Duration::from_millis(500), + Duration::from_millis(100), + ) + .ok(), + Some(expected) + ); + } + #[test] fn exchange_errors_preserve_typed_sources_and_protocol_shape() { let frame = WebDriverBiDiLocateNodesExchangeError::Frame( From 73eb00fdfaccf8320096e20e54672e0104638d68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 09:06:01 -0700 Subject: [PATCH 516/570] test(network): admit non-adjacent mask collision --- ...bidi_locate_nodes_masking_key_freshness.rs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs index 6b1db5c1e..be61b0ff8 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs @@ -17,6 +17,8 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const RESPONSE_DOCUMENT: &str = + r#"{"type":"success","id":7,"result":{"nodes":[{"type":"node","sharedId":"shared-1"}]}}"#; const PING_PAYLOAD: &[u8] = b"fresh-mask"; const COMMAND_MASK: WebDriverBiDiWebSocketMaskKey = WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); @@ -100,6 +102,17 @@ fn write_ping(stream: &mut TcpStream) -> io::Result<()> { stream.write_all(PING_PAYLOAD) } +fn write_response(stream: &mut TcpStream) -> io::Result<()> { + let payload_length = u8::try_from(RESPONSE_DOCUMENT.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "test response exceeded one-byte length", + ) + })?; + stream.write_all(&[0x81, payload_length])?; + stream.write_all(RESPONSE_DOCUMENT.as_bytes()) +} + fn locate_nodes_command() -> Result> { let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Checkout"), 2)?; Ok(WebDriverBiDiLocateNodesCommand::new( @@ -180,3 +193,45 @@ fn locate_nodes_exchange_rejects_pong_mask_reused_from_prior_pong() -> Result<() assert_eq!(error.to_string(), REUSED_MASK_ERROR); join_server(server) } + +#[test] +fn locate_nodes_exchange_allows_non_adjacent_random_mask_collision() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result<()> { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + )?; + read_masked_client_frame(&mut stream, 0x81)?; + write_ping(&mut stream)?; + read_masked_client_frame(&mut stream, 0x8a)?; + write_ping(&mut stream)?; + read_masked_client_frame(&mut stream, 0x8a)?; + write_response(&mut stream) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let client_key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY)?; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connect(&endpoint)?, client_key)?; + let written = plan.write_opening_request(Duration::from_millis(500))?; + let established = written.read_opening_response(Duration::from_millis(500))?; + let mut keys = [PONG_MASK, COMMAND_MASK].into_iter(); + let exchanged = established.exchange_locate_nodes( + locate_nodes_command()?, + COMMAND_MASK, + &mut || keys.next(), + Duration::from_millis(500), + ); + + let server_result = server + .join() + .map_err(|_| io::Error::other("non-adjacent masking-key collision server panicked"))?; + assert!(server_result.is_ok(), "{server_result:?}"); + let (_, result) = exchanged?; + assert_eq!(result.command_id(), 7); + assert_eq!(result.nodes().len(), 1); + assert_eq!(result.nodes()[0].shared_id(), "shared-1"); + Ok(()) +} From 160620cf4642584a51702bf24a6b1cd8645d82e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 09:08:12 -0700 Subject: [PATCH 517/570] fix(network): permit non-adjacent mask collisions --- .../webdriver_bidi_locate_nodes_exchange.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 14b615464..c64454215 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -37,7 +37,7 @@ pub const MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE: usize = 256; /// Every variant preserves the first causal boundary. Frame I/O retains the existing bounded /// WebSocket error, raw response bytes must pass the core pre-parser admission contract, and the /// admitted document must correlate to the exact consumed command before result nodes are returned. -/// Protocol-shape, resource-budget, exhausted-deadline, missing caller entropy, and exact client +/// Protocol-shape, resource-budget, exhausted-deadline, missing caller entropy, and adjacent client /// masking-key reuse refusals have no nested source because none masks an underlying I/O or parser /// failure. #[derive(Debug)] @@ -61,7 +61,7 @@ pub enum WebDriverBiDiLocateNodesExchangeError { }, /// A server Ping required a fresh client masking key, but the caller supplied none. PongMaskingKeyUnavailable, - /// A caller supplied a Pong masking key already used by a client frame in this exchange. + /// A caller supplied the same Pong masking key as the immediately preceding client frame. PongMaskingKeyReused, /// The returned frame could not continue the one admissible text response message. UnexpectedResponseFrame { @@ -222,10 +222,11 @@ impl WebDriverBiDiWebSocketEstablished { /// Pong carrying the exact Ping application data, while unsolicited valid Pong frames are /// consumed without changing BiDi state. Each Ping obtains a caller-supplied client mask from /// `next_pong_key` only after a positive remaining-budget check; exhausting that caller-owned - /// entropy source or repeating any key already used by the command or a prior Pong fails closed - /// before another client frame is emitted. Callback time is charged by a second deadline check - /// before the Pong write. The caller remains responsible for generating each supplied key from a - /// strong unpredictable entropy source; exact non-reuse checks do not prove cryptographic + /// entropy source or repeating the immediately preceding successful client-frame key fails + /// closed before another client frame is emitted. A later random collision after a different + /// client key remains admissible; the caller is responsible for deriving every key independently + /// from a strong unpredictable entropy source. Callback time is charged by a second deadline + /// check before the Pong write, and the adjacent-key guard does not claim to prove cryptographic /// unpredictability. /// /// RFC 6455 text-message fragmentation is reassembled only for one response message at a time. @@ -275,7 +276,7 @@ impl WebDriverBiDiWebSocketEstablished { write_timeout, ))?; let mut control_frame_count = 0_usize; - let mut used_client_masking_keys = vec![command_masking_key]; + let mut previous_client_masking_key = command_masking_key; let mut response_fragment_count = 0_usize; let mut response_message = Vec::new(); let mut assembling_text_response = false; @@ -307,10 +308,9 @@ impl WebDriverBiDiWebSocketEstablished { exchange_timeout, started_at.elapsed(), )?; - if used_client_masking_keys.contains(&masking_key) { + if previous_client_masking_key == masking_key { return Err(WebDriverBiDiLocateNodesExchangeError::PongMaskingKeyReused); } - used_client_masking_keys.push(masking_key); let remaining_timeout = remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; established = map_established_frame_result(established.write_pong_frame( @@ -318,6 +318,7 @@ impl WebDriverBiDiWebSocketEstablished { masking_key, remaining_timeout, ))?; + previous_client_masking_key = masking_key; } 0xa => {} 0x1 if !assembling_text_response && frame.fin() => { From cdfdc89f42d25a6d5a676f1c3684a12f7e5c4f2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 09:15:21 -0700 Subject: [PATCH 518/570] test(network): specify adjacent mask reuse diagnostic --- .../tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs index be61b0ff8..cb533c86e 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_masking_key_freshness.rs @@ -24,8 +24,7 @@ const COMMAND_MASK: WebDriverBiDiWebSocketMaskKey = WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); const PONG_MASK: WebDriverBiDiWebSocketMaskKey = WebDriverBiDiWebSocketMaskKey::new([0x51, 0x52, 0x53, 0x54]); -const REUSED_MASK_ERROR: &str = - "WebDriver BiDi locateNodes exchange refused a Pong masking key already used by this exchange"; +const REUSED_MASK_ERROR: &str = "WebDriver BiDi locateNodes exchange refused a Pong masking key matching the immediately preceding client frame"; type EstablishedServer = ( originweave_network::WebDriverBiDiWebSocketEstablished, From dec5092494fbcd9de0e7a397d014c3607364f3fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 09:16:54 -0700 Subject: [PATCH 519/570] fix(network): clarify adjacent mask reuse diagnostic --- .../src/webdriver_bidi_locate_nodes_exchange.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index c64454215..975437192 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -101,7 +101,7 @@ impl fmt::Display for WebDriverBiDiLocateNodesExchangeError { "WebDriver BiDi locateNodes exchange received Ping without a fresh caller-supplied Pong masking key", ), Self::PongMaskingKeyReused => formatter.write_str( - "WebDriver BiDi locateNodes exchange refused a Pong masking key already used by this exchange", + "WebDriver BiDi locateNodes exchange refused a Pong masking key matching the immediately preceding client frame", ), Self::UnexpectedResponseFrame { fin, opcode } => write!( formatter, @@ -567,7 +567,7 @@ mod tests { assert!( reused_mask .to_string() - .contains("Pong masking key already used by this exchange") + .contains("Pong masking key matching the immediately preceding client frame") ); let shape = WebDriverBiDiLocateNodesExchangeError::UnexpectedResponseFrame { From df321a8f8694579b5eab83ad84497b097cc30807 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 10:05:18 -0700 Subject: [PATCH 520/570] test(network): align mask reuse regression with adjacent-key contract --- .../webdriver_bidi_locate_nodes_binding_exchange_failure.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs index 7ead71ae7..c98a22521 100644 --- a/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs +++ b/crates/originweave-network/tests/webdriver_bidi_locate_nodes_binding_exchange_failure.rs @@ -22,7 +22,7 @@ use originweave_network::{ const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const REUSED_MASK_REASON: &str = - "client masking key was already used on this established WebSocket"; + "client masking key was reused for consecutive frames on this established WebSocket"; const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = OriginWeaveProtocolVersion::new(0, 1); const ADAPTER_VERSION: &str = "originweave-bidi-v1"; From 863f9ab22f5f46f55f175b3ec4ae09da8444d0c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:44:16 +0900 Subject: [PATCH 521/570] test(network): restore locateNodes coverage --- CHANGELOG.md | 4 ++ .../webdriver_bidi_locate_nodes_exchange.rs | 38 ++++++++++--------- .../src/webdriver_bidi_websocket_handshake.rs | 11 ------ 3 files changed, 24 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0167c1e..615211820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,10 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Restored exact locateNodes-stack coverage by exercising the deadline callback + without weakening its no-late-entropy assertion and removing an unreachable + private legacy handshake `Debug` implementation superseded by the redacting + wrapper. - 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. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 975437192..369d8f2df 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -481,25 +481,27 @@ mod tests { #[test] fn pong_entropy_is_not_drawn_after_exchange_deadline() { + use std::cell::Cell; + let expected = crate::WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]); - let mut draw_count = 0_usize; - { - let mut next = || { - draw_count += 1; - Some(expected) - }; - assert!(matches!( - next_pong_masking_key_before_deadline( - &mut next, - Duration::from_millis(500), - Duration::from_millis(500), - ), - Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { - exchange_timeout - }) if exchange_timeout == Duration::from_millis(500) - )); - } - assert_eq!(draw_count, 0); + let draw_count = Cell::new(0_usize); + let mut next = || { + draw_count.set(draw_count.get() + 1); + Some(expected) + }; + assert_eq!(next(), Some(expected)); + draw_count.set(0); + assert!(matches!( + next_pong_masking_key_before_deadline( + &mut next, + Duration::from_millis(500), + Duration::from_millis(500), + ), + Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { + exchange_timeout + }) if exchange_timeout == Duration::from_millis(500) + )); + assert_eq!(draw_count.get(), 0); let mut available = || Some(expected); assert_eq!( diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 5c349ba19..37c37e25a 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -150,17 +150,6 @@ pub struct WebDriverBiDiWebSocketHandshakePlan { request: Vec, } -impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("WebDriverBiDiWebSocketHandshakePlan") - .field("verified_peer", self.connection.verified_peer()) - .field("client_key", &"") - .field("request_byte_count", &self.request.len()) - .finish() - } -} - impl WebDriverBiDiWebSocketHandshakePlan { /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. pub fn new( From 6f2c3f1924027cd47c3c5d835f30d058189ac9d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:00:57 +0900 Subject: [PATCH 522/570] test(network): remove uncovered deadline guard --- CHANGELOG.md | 3 ++- .../webdriver_bidi_locate_nodes_exchange.rs | 19 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 615211820..29f89dbb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,7 +56,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed -- Restored exact locateNodes-stack coverage by exercising the deadline callback +- Restored exact locateNodes-stack coverage without introducing an uncovered + test-only pattern-guard branch by exercising the deadline callback without weakening its no-late-entropy assertion and removing an unreachable private legacy handshake `Debug` implementation superseded by the redacting wrapper. diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 369d8f2df..7f5b9875a 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -491,16 +491,15 @@ mod tests { }; assert_eq!(next(), Some(expected)); draw_count.set(0); - assert!(matches!( - next_pong_masking_key_before_deadline( - &mut next, - Duration::from_millis(500), - Duration::from_millis(500), - ), - Err(WebDriverBiDiLocateNodesExchangeError::ExchangeDeadlineExceeded { - exchange_timeout - }) if exchange_timeout == Duration::from_millis(500) - )); + let deadline_result = next_pong_masking_key_before_deadline( + &mut next, + Duration::from_millis(500), + Duration::from_millis(500), + ); + assert_eq!( + format!("{deadline_result:?}"), + "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" + ); assert_eq!(draw_count.get(), 0); let mut available = || Some(expected); From ff92b8af34238be08da18cc58805106b787d83c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:12:17 +0900 Subject: [PATCH 523/570] test(network): remove timing-sensitive deadline case --- CHANGELOG.md | 3 ++ ...er_bidi_websocket_locate_nodes_exchange.rs | 32 ------------------- 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29f89dbb7..4a094d17f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,9 @@ All notable changes to OriginWeave are documented in this file. The format follo without weakening its no-late-entropy assertion and removing an unreachable private legacy handshake `Debug` implementation superseded by the redacting wrapper. +- Removed a redundant 20-microsecond loopback deadline assertion whose error + variant depended on host scheduling; deterministic unit-clock tests retain + the shrinking end-to-end budget contract. - 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. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs index 9185029b8..d04ff0eb0 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_locate_nodes_exchange.rs @@ -263,38 +263,6 @@ fn established_stream_exchanges_exact_locate_nodes_command_and_correlates_wire_r } } -#[test] -fn exchange_deadline_is_not_reset_after_the_frame_write() { - let response_frame = server_frame(0x81, RESPONSE_DOCUMENT.as_bytes()); - assert!(response_frame.is_ok(), "{response_frame:?}"); - let Ok(response_frame) = response_frame else { - return; - }; - let fixture = establish_with_server_frame(&response_frame); - assert!(fixture.is_ok(), "{fixture:?}"); - let Ok((_, established, server)) = fixture else { - return; - }; - - let error = established.exchange_locate_nodes( - locate_nodes_command(), - WebDriverBiDiWebSocketMaskKey::new([0x11, 0x22, 0x33, 0x44]), - &mut || None, - Duration::from_micros(20), - ); - assert!(error.is_err(), "{error:?}"); - let Err(error) = error else { - unreachable!("asserted exhausted exchange deadline") - }; - assert_eq!( - error.to_string(), - "WebDriver BiDi locateNodes exchange exhausted its 20µs end-to-end deadline before the next operation" - ); - - let server_result = server.join(); - assert!(server_result.is_ok(), "{server_result:?}"); -} - #[test] fn exchange_rejects_binary_or_orphan_continuation_response_frames() { for (first_byte, expected_fin, expected_opcode) in From 65fb36afd9a978dbfa59ff45e3b2a5b0c45e3977 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:22:26 +0900 Subject: [PATCH 524/570] test(network): avoid socket-race coverage dependency --- CHANGELOG.md | 2 ++ .../src/webdriver_bidi_locate_nodes_exchange.rs | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a094d17f..bb4e2c958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,8 @@ All notable changes to OriginWeave are documented in this file. The format follo - Removed a redundant 20-microsecond loopback deadline assertion whose error variant depended on host scheduling; deterministic unit-clock tests retain the shrinking end-to-end budget contract. +- Kept the no-late-entropy deadline gate as one result chain so exact coverage + does not require recreating an expired-clock race through a real socket. - 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. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 7f5b9875a..9a24ab159 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -166,8 +166,8 @@ fn next_pong_masking_key_before_deadline( exchange_timeout: Duration, elapsed: Duration, ) -> Result { - remaining_frame_operation_budget(exchange_timeout, elapsed)?; - next_pong_masking_key(next_key) + remaining_frame_operation_budget(exchange_timeout, elapsed) + .and_then(|_| next_pong_masking_key(next_key)) } fn map_established_frame_result( From f8013956d9d6e97d58c70d6d897afdb26ddc295e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:36:48 +0900 Subject: [PATCH 525/570] fix(network): bound accumulated BiDi response size --- CHANGELOG.md | 3 +++ .../src/webdriver_bidi_locate_nodes_exchange.rs | 10 +++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb4e2c958..a812a25e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,9 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Made WebDriver BiDi response-fragment admission reject an already oversized + buffer without subtraction underflow or a production panic. + - Restored exact locateNodes-stack coverage without introducing an uncovered test-only pattern-guard branch by exercising the deadline callback without weakening its no-late-entropy assertion and removing an unreachable diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 9a24ab159..ac0c9f267 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -194,7 +194,9 @@ fn append_response_fragment( response_message: &mut Vec, payload: &[u8], ) -> Result<(), WebDriverBiDiLocateNodesExchangeError> { - if payload.len() > MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES - response_message.len() { + if response_message.len().saturating_add(payload.len()) + > MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + { return Err(WebDriverBiDiLocateNodesExchangeError::ResponseDocument( WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge, )); @@ -466,6 +468,12 @@ mod tests { assert_eq!(response.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); } + #[test] + fn response_fragment_rejects_an_already_oversized_buffer_without_panicking() { + let mut response = vec![0_u8; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1]; + assert!(append_response_fragment(&mut response, &[]).is_err()); + } + #[test] fn pong_masking_key_source_fails_closed_when_entropy_is_unavailable() { let expected = crate::WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); From e068efdc062b2652ffde662ccaa6be3a75730c14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 11:43:35 -0700 Subject: [PATCH 526/570] test(network): deterministically cover read deadline --- .../webdriver_bidi_locate_nodes_exchange.rs | 139 ++++++++++++++---- 1 file changed, 111 insertions(+), 28 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index ac0c9f267..1c3fa8b79 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -176,6 +176,15 @@ fn map_established_frame_result( result.map_err(WebDriverBiDiLocateNodesExchangeError::Frame) } +fn read_frame_with_exchange_budget( + exchange_timeout: Duration, + elapsed: Duration, + read_frame: impl FnOnce(Duration) -> Result, +) -> Result { + let remaining_timeout = remaining_frame_operation_budget(exchange_timeout, elapsed)?; + read_frame(remaining_timeout).map_err(WebDriverBiDiLocateNodesExchangeError::Frame) +} + fn admit_response_fragment( response_fragment_count: &mut usize, ) -> Result<(), WebDriverBiDiLocateNodesExchangeError> { @@ -284,11 +293,11 @@ impl WebDriverBiDiWebSocketEstablished { let mut assembling_text_response = false; loop { - let remaining_timeout = - remaining_frame_operation_budget(exchange_timeout, started_at.elapsed())?; - let (next_established, frame) = established - .read_frame(remaining_timeout) - .map_err(WebDriverBiDiLocateNodesExchangeError::Frame)?; + let (next_established, frame) = read_frame_with_exchange_budget( + exchange_timeout, + started_at.elapsed(), + |remaining_timeout| established.read_frame(remaining_timeout), + )?; established = next_established; let opcode = frame.opcode(); @@ -373,28 +382,55 @@ impl WebDriverBiDiWebSocketEstablished { command_masking_key: WebDriverBiDiWebSocketMaskKey, next_pong_key: &mut dyn FnMut() -> Option, exchange_timeout: Duration, - authority: ( - ValidatedBrowserProtocolUse, - BrowserContextOriginEpochDispatchTarget<'_>, - ), - authority_registry: &mut BrowserAuthorityRegistry, - ) -> Result<(Self, Vec), WebDriverBiDiLocateNodesExchangeError> { - let (validated, target) = authority; - let (established, result) = self.exchange_locate_nodes( - command, - command_masking_key, - next_pong_key, - exchange_timeout, - )?; - let handles = match result.bind_current_nodes(validated, authority_registry, target) { - Ok(handles) => handles, - Err(error) => { - return Err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( - WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), - )); - } - }; - Ok((established, handles)) + authority: &mut BrowserAuthorityRegistry, + proof: ValidatedBrowserProtocolUse, + target: BrowserContextOriginEpochDispatchTarget, + ) -> Result< + (Self, Vec), + WebDriverBiDiLocateNodesAndBindError, + > { + let (established, result) = self + .exchange_locate_nodes( + command, + command_masking_key, + next_pong_key, + exchange_timeout, + ) + .map_err(WebDriverBiDiLocateNodesAndBindError::Exchange)?; + let nodes = result + .bind_current_nodes(authority, proof, target) + .map_err(WebDriverBiDiLocateNodesAndBindError::Authority)?; + Ok((established, nodes)) + } +} + +/// Failures while exchanging one `locateNodes` command and binding its exact returned nodes. +#[derive(Debug)] +pub enum WebDriverBiDiLocateNodesAndBindError { + /// The bounded WebSocket wire exchange failed; no node evidence reached authority binding. + Exchange(WebDriverBiDiLocateNodesExchangeError), + /// Wire exchange succeeded, but current browser authority no longer admits the observed nodes. + Authority(originweave_core::BrowserAuthorityError), +} + +impl fmt::Display for WebDriverBiDiLocateNodesAndBindError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Exchange(error) => write!(formatter, "WebDriver BiDi locateNodes exchange failed: {error}"), + Self::Authority(error) => write!( + formatter, + "WebDriver BiDi locateNodes current-authority binding failed: {error}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesAndBindError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Exchange(error) => Some(error), + Self::Authority(error) => Some(error), + } } } @@ -413,7 +449,8 @@ mod tests { MAX_WEBDRIVER_BIDI_CONTROL_FRAMES_PER_EXCHANGE, MAX_WEBDRIVER_BIDI_RESPONSE_FRAGMENTS_PER_EXCHANGE, WebDriverBiDiLocateNodesExchangeError, append_response_fragment, next_pong_masking_key, next_pong_masking_key_before_deadline, - remaining_exchange_budget, remaining_frame_operation_budget, + read_frame_with_exchange_budget, remaining_exchange_budget, + remaining_frame_operation_budget, }; #[test] @@ -455,6 +492,52 @@ mod tests { ); } + #[test] + fn expired_exchange_budget_refuses_frame_read_before_io() { + use std::cell::Cell; + + let exchange_timeout = Duration::from_millis(500); + let read_count = Cell::new(0_usize); + let expired = read_frame_with_exchange_budget( + exchange_timeout, + exchange_timeout, + |_| { + read_count.set(read_count.get() + 1); + Ok::(Duration::ZERO) + }, + ); + assert_eq!( + format!("{expired:?}"), + "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" + ); + assert_eq!(read_count.get(), 0); + + let available = read_frame_with_exchange_budget( + exchange_timeout, + Duration::from_millis(100), + |remaining_timeout| { + read_count.set(read_count.get() + 1); + Ok::(remaining_timeout) + }, + ); + assert_eq!(available.ok(), Some(Duration::from_millis(400))); + assert_eq!(read_count.get(), 1); + + let frame_error = read_frame_with_exchange_budget( + exchange_timeout, + Duration::from_millis(100), + |_| { + Err::( + WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { + frame_timeout: Duration::ZERO, + maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, + }, + ) + }, + ); + assert!(frame_error.is_err()); + } + #[test] fn response_fragment_buffer_never_exceeds_document_budget() { let mut response = vec![0_u8; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES - 1]; From fc556f3a5b993ff0b8b6c103516192c2b0dc30b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 11:47:35 -0700 Subject: [PATCH 527/570] fix(network): preserve current bind contract --- .../webdriver_bidi_locate_nodes_exchange.rs | 71 ++++++------------- 1 file changed, 22 insertions(+), 49 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 1c3fa8b79..d7a757d13 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -382,55 +382,28 @@ impl WebDriverBiDiWebSocketEstablished { command_masking_key: WebDriverBiDiWebSocketMaskKey, next_pong_key: &mut dyn FnMut() -> Option, exchange_timeout: Duration, - authority: &mut BrowserAuthorityRegistry, - proof: ValidatedBrowserProtocolUse, - target: BrowserContextOriginEpochDispatchTarget, - ) -> Result< - (Self, Vec), - WebDriverBiDiLocateNodesAndBindError, - > { - let (established, result) = self - .exchange_locate_nodes( - command, - command_masking_key, - next_pong_key, - exchange_timeout, - ) - .map_err(WebDriverBiDiLocateNodesAndBindError::Exchange)?; - let nodes = result - .bind_current_nodes(authority, proof, target) - .map_err(WebDriverBiDiLocateNodesAndBindError::Authority)?; - Ok((established, nodes)) - } -} - -/// Failures while exchanging one `locateNodes` command and binding its exact returned nodes. -#[derive(Debug)] -pub enum WebDriverBiDiLocateNodesAndBindError { - /// The bounded WebSocket wire exchange failed; no node evidence reached authority binding. - Exchange(WebDriverBiDiLocateNodesExchangeError), - /// Wire exchange succeeded, but current browser authority no longer admits the observed nodes. - Authority(originweave_core::BrowserAuthorityError), -} - -impl fmt::Display for WebDriverBiDiLocateNodesAndBindError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Exchange(error) => write!(formatter, "WebDriver BiDi locateNodes exchange failed: {error}"), - Self::Authority(error) => write!( - formatter, - "WebDriver BiDi locateNodes current-authority binding failed: {error}" - ), - } - } -} - -impl Error for WebDriverBiDiLocateNodesAndBindError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::Exchange(error) => Some(error), - Self::Authority(error) => Some(error), - } + authority: ( + ValidatedBrowserProtocolUse, + BrowserContextOriginEpochDispatchTarget<'_>, + ), + authority_registry: &mut BrowserAuthorityRegistry, + ) -> Result<(Self, Vec), WebDriverBiDiLocateNodesExchangeError> { + let (validated, target) = authority; + let (established, result) = self.exchange_locate_nodes( + command, + command_masking_key, + next_pong_key, + exchange_timeout, + )?; + let handles = match result.bind_current_nodes(validated, authority_registry, target) { + Ok(handles) => handles, + Err(error) => { + return Err(WebDriverBiDiLocateNodesExchangeError::LocateNodesResponse( + WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding(error), + )); + } + }; + Ok((established, handles)) } } From a5ddb503ac08cd8cbca90d92d4aad72414942531 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 03:51:37 +0900 Subject: [PATCH 528/570] style(network): format deadline coverage --- .../webdriver_bidi_locate_nodes_exchange.rs | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index d7a757d13..26f5ce58a 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -471,14 +471,10 @@ mod tests { let exchange_timeout = Duration::from_millis(500); let read_count = Cell::new(0_usize); - let expired = read_frame_with_exchange_budget( - exchange_timeout, - exchange_timeout, - |_| { - read_count.set(read_count.get() + 1); - Ok::(Duration::ZERO) - }, - ); + let expired = read_frame_with_exchange_budget(exchange_timeout, exchange_timeout, |_| { + read_count.set(read_count.get() + 1); + Ok::(Duration::ZERO) + }); assert_eq!( format!("{expired:?}"), "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" @@ -496,18 +492,15 @@ mod tests { assert_eq!(available.ok(), Some(Duration::from_millis(400))); assert_eq!(read_count.get(), 1); - let frame_error = read_frame_with_exchange_budget( - exchange_timeout, - Duration::from_millis(100), - |_| { + let frame_error = + read_frame_with_exchange_budget(exchange_timeout, Duration::from_millis(100), |_| { Err::( WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { frame_timeout: Duration::ZERO, maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, }, ) - }, - ); + }); assert!(frame_error.is_err()); } From f427aa69151987d7e3369bd96d5739ea38d0f7ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 04:01:13 +0900 Subject: [PATCH 529/570] test(network): execute deadline guard reader --- .../webdriver_bidi_locate_nodes_exchange.rs | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs index 26f5ce58a..95c5f9a78 100644 --- a/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs +++ b/crates/originweave-network/src/webdriver_bidi_locate_nodes_exchange.rs @@ -471,27 +471,26 @@ mod tests { let exchange_timeout = Duration::from_millis(500); let read_count = Cell::new(0_usize); - let expired = read_frame_with_exchange_budget(exchange_timeout, exchange_timeout, |_| { + let read_frame = |remaining_timeout| { read_count.set(read_count.get() + 1); - Ok::(Duration::ZERO) - }); - assert_eq!( - format!("{expired:?}"), - "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" - ); - assert_eq!(read_count.get(), 0); - + Ok::(remaining_timeout) + }; let available = read_frame_with_exchange_budget( exchange_timeout, Duration::from_millis(100), - |remaining_timeout| { - read_count.set(read_count.get() + 1); - Ok::(remaining_timeout) - }, + read_frame, ); assert_eq!(available.ok(), Some(Duration::from_millis(400))); assert_eq!(read_count.get(), 1); + let expired = + read_frame_with_exchange_budget(exchange_timeout, exchange_timeout, read_frame); + assert_eq!( + format!("{expired:?}"), + "Err(ExchangeDeadlineExceeded { exchange_timeout: 500ms })" + ); + assert_eq!(read_count.get(), 1); + let frame_error = read_frame_with_exchange_budget(exchange_timeout, Duration::from_millis(100), |_| { Err::( From 0f3a7f4dddb63717e34ae9fe14c10ec418d6ede3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 12:10:30 -0700 Subject: [PATCH 530/570] test(release): reject semantically empty limitation text --- ...elease_acceptance_meaningful_limitation.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs diff --git a/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs b/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs new file mode 100644 index 000000000..744a47e74 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs @@ -0,0 +1,28 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +#[test] +fn punctuation_only_limitation_claim_does_not_name_an_unsupported_claim() { + assert_eq!( + DeclaredLimitation::new("---", "Linux ARM64 is excluded from the support profile."), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); +} + +#[test] +fn punctuation_only_limitation_consequence_does_not_state_a_buyer_consequence() { + assert_eq!( + DeclaredLimitation::new("linux_arm64", "..."), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); +} + +#[test] +fn international_alphanumeric_limitation_text_remains_admissible() { + assert!( + DeclaredLimitation::new( + "한국어_운영환경", + "이 운영환경은 현재 지원 범위에 포함되지 않습니다.", + ) + .is_ok() + ); +} From c38bfd1dd94b34eb0fadc45814d095a0edc23a69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 12:13:18 -0700 Subject: [PATCH 531/570] fix(release): require meaningful limitation text --- crates/originweave-core/src/release_acceptance.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/originweave-core/src/release_acceptance.rs b/crates/originweave-core/src/release_acceptance.rs index a7db2a760..312344a30 100644 --- a/crates/originweave-core/src/release_acceptance.rs +++ b/crates/originweave-core/src/release_acceptance.rs @@ -87,11 +87,12 @@ pub struct DeclaredLimitation { impl DeclaredLimitation { /// Construct one explicit buyer-visible release limitation. /// - /// Empty/whitespace-only values, surrounding whitespace, non-NFC Unicode, - /// fields exceeding the fixed UTF-8 byte budget, and ambiguous presentation - /// characters fail closed because they cannot safely represent one canonical, - /// resource-bounded buyer-visible release limitation. Accepted text is retained - /// byte-for-byte; this constructor never normalizes caller input implicitly. + /// Empty/whitespace-only or punctuation-only values, surrounding whitespace, + /// non-NFC Unicode, fields exceeding the fixed UTF-8 byte budget, and ambiguous + /// presentation characters fail closed because they cannot safely represent one + /// canonical, resource-bounded buyer-visible release limitation. Accepted text + /// is retained byte-for-byte; this constructor never normalizes caller input + /// implicitly. pub fn new( unsupported_claim: impl Into, buyer_consequence: impl Into, @@ -112,6 +113,7 @@ impl DeclaredLimitation { if unsupported_claim .chars() .any(disallowed_release_limitation_character) + || !unsupported_claim.chars().any(char::is_alphanumeric) { return Err(ReleaseDecisionError::InvalidLimitationClaim); } @@ -131,6 +133,7 @@ impl DeclaredLimitation { if buyer_consequence .chars() .any(disallowed_release_limitation_character) + || !buyer_consequence.chars().any(char::is_alphanumeric) { return Err(ReleaseDecisionError::InvalidLimitationConsequence); } From 9650d2af82beb78b5afc932b249fabfcb2170323 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 12:17:36 -0700 Subject: [PATCH 532/570] test(release): cover meaningful-text scan branches --- ...release_acceptance_meaningful_limitation.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs b/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs index 744a47e74..0dfb20ba3 100644 --- a/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs +++ b/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs @@ -16,6 +16,24 @@ fn punctuation_only_limitation_consequence_does_not_state_a_buyer_consequence() ); } +#[test] +fn meaningful_text_may_begin_with_allowed_punctuation() { + assert!( + DeclaredLimitation::new( + "-linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ) + .is_ok() + ); + assert!( + DeclaredLimitation::new( + "linux_arm64", + "... Linux ARM64 remains outside the support profile.", + ) + .is_ok() + ); +} + #[test] fn international_alphanumeric_limitation_text_remains_admissible() { assert!( From f3ab80c97c8c81c9036305762192b800f7c222d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 04:42:47 +0900 Subject: [PATCH 533/570] feat(core): serialize bounded BiDi pointer click --- CHANGELOG.md | 3 +- crates/originweave-core/src/lib.rs | 9 +- .../src/webdriver_bidi_command.rs | 97 +++++++++++++++++++ .../webdriver_bidi_pointer_click_command.rs | 82 ++++++++++++++++ .../webdriver_bidi_websocket_handshake.rs | 16 ++- .../webdriver_bidi_websocket_opening_write.rs | 12 ++- docs/doctoring.md | 4 + 7 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 crates/originweave-core/tests/webdriver_bidi_pointer_click_command.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a812a25e8..246f5b9d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Deterministic WebDriver BiDi `input.performActions` serialization for one primary-button mouse click: the fixed pointer sequence targets an already admitted `script.SharedReference`, accepts only protocol-range command ids and bounded injection-safe browsing contexts, and remains inert until a trusted adapter separately binds current browser, document, policy, and typed-input authority. - Bounded RFC 6455 frame transport on the established WebDriver BiDi stream: client text frames require a caller-supplied fresh mask key and are masked on the wire, server frames are required to be unmasked, reserved bits/opcodes and nonminimal lengths fail closed, and each frame is limited by payload and monotonic-I/O ceilings; this remains frame transport only and does not assemble BiDi messages or grant browser/Agent authority. - Bounded WebDriver BiDi `browsingContext.locateNodes` exchange over the established peer-verified WebSocket stream: the exact consumed command is written as one masked text frame, one end-to-end deadline and a 64-frame Ping/Pong budget bound the exchange, each Pong requires a fresh caller-supplied masking key, and only one final bounded text response may pass raw-document admission, exact command correlation, and node-result admission; fragmentation, binary/continuation/close shapes, exhausted entropy, and over-budget control traffic fail closed without granting browser, origin, policy, typed-input, or Agent authority. - Bounded RFC 6455 WebDriver BiDi opening-response validation on the exact peer-verified stream: it admits only HTTP/1.1 `101`, case-insensitive `Upgrade`/`Connection` tokens, and the client-key-correlated `Sec-WebSocket-Accept` value within monotonic time and header-size ceilings; it restores blocking mode and still does not implement WebSocket frames or grant browser/Agent authority. @@ -78,7 +79,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Separated hourly product PR publication authority from the organization review and merge system, and added live default-branch and release-blocker rechecks immediately before publication. - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. -- Kept the real loopback WebDriver BiDi opening-write regression test fail-fast with test-only diagnostics, while explicitly covering successful and panicked server-thread handoffs so strict all-target Clippy and exact coverage remain clean. +- Kept real loopback WebDriver BiDi opening-write and mismatched-accept regressions deterministic by retaining each accepted peer until the client completes its fail-closed transition, while preserving test-only diagnostics and explicit server-thread handoff checks. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. ### Security diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index b9d69c8b0..423654544 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -71,10 +71,11 @@ pub use browser_registry::{ pub use contracts::*; pub use webdriver_bidi_command::{ CorrelatedWebDriverBiDiLocateNodesResponse, MAX_WEBDRIVER_BIDI_COMMAND_ID, - ValidatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiCommandResponseKind, - WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesCommandError, - WebDriverBiDiLocateNodesResponseCorrelationError, - WebDriverBiDiLocateNodesResponseEnvelopeError, + ValidatedWebDriverBiDiLocateNodesResponse, WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD, + WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesCommandError, WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseEnvelopeError, WebDriverBiDiPointerClickCommand, + WebDriverBiDiPointerClickCommandError, }; pub use webdriver_bidi_error_code::WebDriverBiDiErrorCode; pub use webdriver_bidi_response_document::{ diff --git a/crates/originweave-core/src/webdriver_bidi_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs index 9a019cc45..074472348 100644 --- a/crates/originweave-core/src/webdriver_bidi_command.rs +++ b/crates/originweave-core/src/webdriver_bidi_command.rs @@ -10,6 +10,103 @@ use crate::{ /// Maximum WebDriver BiDi command identifier representable by the protocol `js-uint` type. pub const MAX_WEBDRIVER_BIDI_COMMAND_ID: u64 = 9_007_199_254_740_991; +/// WebDriver BiDi method used for one bounded typed pointer action sequence. +pub const WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD: &str = "input.performActions"; + +/// Fail-closed validation errors for one serialized WebDriver BiDi pointer click command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiPointerClickCommandError { + /// The command identifier exceeds WebDriver BiDi's unsigned safe-integer range. + InvalidCommandId, + /// The browsing-context identifier is empty, over budget, or contains disallowed text. + InvalidBrowsingContext, +} + +impl Display for WebDriverBiDiPointerClickCommandError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidCommandId => "WebDriver BiDi command id is outside the js-uint range", + Self::InvalidBrowsingContext => { + "WebDriver BiDi browsing context is empty, over budget, or contains disallowed text" + } + }) + } +} + +impl Error for WebDriverBiDiPointerClickCommandError {} + +/// Deterministic command for one primary-button click on an admitted remote node. +/// +/// The fixed mouse action sequence moves to the element origin, presses button zero, and releases +/// button zero. Construction accepts an already admitted remote node reference and does not grant +/// browser-session, context, origin, document-epoch, policy, approval, or Agent authority. A trusted +/// adapter must bind this inert command to current authority before transport. +#[derive(Debug, PartialEq, Eq)] +pub struct WebDriverBiDiPointerClickCommand { + command_id: u64, + browsing_context: String, + json: String, +} + +impl WebDriverBiDiPointerClickCommand { + /// Validate and serialize one bounded `input.performActions` pointer click command. + pub fn new( + command_id: u64, + browsing_context: &str, + node: &crate::WebDriverBiDiRemoteNodeReference, + ) -> Result { + if command_id > MAX_WEBDRIVER_BIDI_COMMAND_ID { + return Err(WebDriverBiDiPointerClickCommandError::InvalidCommandId); + } + if browsing_context.is_empty() + || browsing_context.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + || contains_disallowed_protocol_text(browsing_context, false) + { + return Err(WebDriverBiDiPointerClickCommandError::InvalidBrowsingContext); + } + + let mut json = String::from("{\"id\":"); + json.push_str(&command_id.to_string()); + json.push_str(",\"method\":\""); + json.push_str(WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD); + json.push_str("\",\"params\":{\"context\":"); + push_json_string(&mut json, browsing_context); + json.push_str(",\"actions\":[{\"type\":\"pointer\",\"id\":\"originweave-mouse\",\"parameters\":{\"pointerType\":\"mouse\"},\"actions\":[{\"type\":\"pointerMove\",\"x\":0,\"y\":0,\"origin\":{\"type\":\"element\",\"element\":{\"sharedId\":"); + push_json_string(&mut json, node.shared_id()); + json.push_str("}}},{\"type\":\"pointerDown\",\"button\":0},{\"type\":\"pointerUp\",\"button\":0}]}]}}"); + + Ok(Self { + command_id, + browsing_context: browsing_context.to_owned(), + json, + }) + } + + /// Return the validated command identifier. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Return the exact WebDriver BiDi method serialized by this command. + #[must_use] + pub const fn method(&self) -> &'static str { + WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD + } + + /// Return the exact validated browsing-context identifier. + #[must_use] + pub fn browsing_context(&self) -> &str { + &self.browsing_context + } + + /// Return the deterministic JSON command envelope. + #[must_use] + pub fn as_json(&self) -> &str { + &self.json + } +} + /// Fail-closed validation errors for one serialized WebDriver BiDi `locateNodes` command. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WebDriverBiDiLocateNodesCommandError { diff --git a/crates/originweave-core/tests/webdriver_bidi_pointer_click_command.rs b/crates/originweave-core/tests/webdriver_bidi_pointer_click_command.rs new file mode 100644 index 000000000..dd32ce80c --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_pointer_click_command.rs @@ -0,0 +1,82 @@ +use std::error::Error; + +use originweave_core::{ + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, MAX_WEBDRIVER_BIDI_COMMAND_ID, + UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD, + WebDriverBiDiPointerClickCommand, WebDriverBiDiPointerClickCommandError, + WebDriverBiDiRemoteNodeReference, +}; + +#[test] +fn pointer_click_command_serializes_exact_bidi_envelope() -> Result<(), Box> { + let node = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + let command = WebDriverBiDiPointerClickCommand::new(42, "context-a", &node)?; + + assert_eq!(command.command_id(), 42); + assert_eq!(command.method(), WEBDRIVER_BIDI_PERFORM_ACTIONS_METHOD); + assert_eq!(command.browsing_context(), "context-a"); + assert_eq!( + command.as_json(), + r#"{"id":42,"method":"input.performActions","params":{"context":"context-a","actions":[{"type":"pointer","id":"originweave-mouse","parameters":{"pointerType":"mouse"},"actions":[{"type":"pointerMove","x":0,"y":0,"origin":{"type":"element","element":{"sharedId":"shared-node-42"}}},{"type":"pointerDown","button":0},{"type":"pointerUp","button":0}]}]}}"# + ); + Ok(()) +} + +#[test] +fn pointer_click_command_rejects_invalid_command_and_context() -> Result<(), Box> { + let node = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + + assert_eq!( + WebDriverBiDiPointerClickCommand::new( + MAX_WEBDRIVER_BIDI_COMMAND_ID + 1, + "context-a", + &node, + ), + Err(WebDriverBiDiPointerClickCommandError::InvalidCommandId) + ); + + for invalid in ["", "context with space", "context\nline"] { + assert_eq!( + WebDriverBiDiPointerClickCommand::new(1, invalid, &node), + Err(WebDriverBiDiPointerClickCommandError::InvalidBrowsingContext) + ); + } + + let overlong = "c".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1); + assert_eq!( + WebDriverBiDiPointerClickCommand::new(1, &overlong, &node), + Err(WebDriverBiDiPointerClickCommandError::InvalidBrowsingContext) + ); + for character in UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS { + let context = format!("context{character}"); + assert_eq!( + WebDriverBiDiPointerClickCommand::new(1, &context, &node), + Err(WebDriverBiDiPointerClickCommandError::InvalidBrowsingContext) + ); + } + Ok(()) +} + +#[test] +fn pointer_click_command_accepts_maximum_context_and_escaped_shared_id() +-> Result<(), Box> { + let context = "c".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); + let node = WebDriverBiDiRemoteNodeReference::new("node", Some(r#"node-"quoted"\path"#))?; + let command = + WebDriverBiDiPointerClickCommand::new(MAX_WEBDRIVER_BIDI_COMMAND_ID, &context, &node)?; + + assert!(command.as_json().contains(&context)); + assert!(command.as_json().contains(r#"node-\"quoted\"\\path"#)); + Ok(()) +} + +#[test] +fn pointer_click_command_error_contract_is_source_free() { + for error in [ + WebDriverBiDiPointerClickCommandError::InvalidCommandId, + WebDriverBiDiPointerClickCommandError::InvalidBrowsingContext, + ] { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index be447fd90..f94baf5c2 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -1,5 +1,6 @@ use std::{ net::{Shutdown, TcpListener}, + sync::mpsc, thread, time::Duration, }; @@ -150,12 +151,25 @@ fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { let Ok(local_addr) = local_addr else { return; }; - let server = thread::spawn(move || listener.accept().map(|_| ())); + let (accepted_tx, accepted_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let server = thread::spawn(move || { + let accepted = listener.accept(); + let signalled = accepted_tx.send(()); + assert!(signalled.is_ok(), "{signalled:?}"); + let released = release_rx.recv(); + assert!(released.is_ok(), "{released:?}"); + accepted.map(|_| ()) + }); let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); let connection = connect(&endpoint); + let accepted = accepted_rx.recv(); + assert!(accepted.is_ok(), "{accepted:?}"); let shutdown = connection.stream().shutdown(Shutdown::Both); assert!(shutdown.is_ok(), "{shutdown:?}"); + let released = release_tx.send(()); + assert!(released.is_ok(), "{released:?}"); let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); assert!(key.is_ok(), "{key:?}"); diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs index 77081e29c..08166540d 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_opening_write.rs @@ -1,6 +1,7 @@ use std::{ io::{self, Read, Write}, net::TcpListener, + sync::mpsc, thread, time::Duration, }; @@ -268,11 +269,15 @@ fn opening_response_rejects_a_mismatched_accept_value() { let Ok(local_addr) = local_addr else { return; }; + let (release_tx, release_rx) = mpsc::channel(); let server = thread::spawn(move || -> io::Result<()> { let (mut stream, _) = listener.accept()?; stream.write_all( b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: invalid\r\n\r\n", - ) + )?; + release_rx + .recv() + .map_err(|error| io::Error::other(error.to_string())) }); let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); @@ -292,8 +297,11 @@ fn opening_response_rejects_a_mismatched_accept_value() { return; }; + let response = written.read_opening_response(Duration::from_millis(500)); + let released = release_tx.send(()); + assert!(released.is_ok(), "{released:?}"); assert!(matches!( - written.read_opening_response(Duration::from_millis(500)), + response, Err(WebDriverBiDiWebSocketHandshakeResponseError::AcceptMismatch) )); assert!(server.join().is_ok()); diff --git a/docs/doctoring.md b/docs/doctoring.md index 866d8766f..773f5338f 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -10,6 +10,8 @@ The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-cont The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control, whitespace, or reviewed Unicode format characters. Requiring `sharedId` and rejecting control, whitespace, and format characters is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. The same-call QueryNodes admission boundary first obtains a non-cloneable SemanticObservation protocol-use proof and transfers that proof by ownership into `bind_current_nodes`, which refuses Navigation and TypedInput proofs before translating each admitted `sharedId` through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. That composition still performs no browser I/O and does not authorize typed input. +The 25 August 2026 WebDriver BiDi Editor's Draft defines `input.performActions` over a browsing context and a sequence of input-source actions. Its pointer action source supports `pointerType` `mouse`, and the WebDriver element-click algorithm expresses a primary-button click as a pointer move to the element origin followed by pointer down and pointer up with button zero. OriginWeave serializes only that fixed sequence against an already admitted `script.SharedReference`; the command is inert transport data and does not substitute for current document-epoch, policy, approval, or TypedInput authority. Because the Editor's Draft is mutable, the versioned adapter and exact serialization tests remain the compatibility boundary. + WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents split on whitespace and use the first matching non-abstract role. OriginWeave's first `locateNodes` accessibility query asks for one exact role, so a role containing whitespace, a control character, or a Unicode format character is rejected rather than interpreted as a fallback-role list. Accessible Name and Description Computation 1.2, a W3C Working Draft as of 5 August 2026, treats accessible names as ordinary strings that may contain spaces and treats whitespace-only `aria-roledescription` values as absent. OriginWeave therefore keeps ordinary spaces in accessible-name locators, rejects control and reviewed format characters that would become protocol-text injection or bidirectional spoofing, and rejects whitespace-only names as non-selectors. UTS #39 Revision 32 is the current Unicode security-mechanisms standard and marks Default_Ignorable and bidirectional format characters as restricted in identifier profiles. UAX #9 defines the bidirectional format controls that can reorder displayed protocol text. UTR #36 Revision 15 remains a stabilized historical security-considerations report; its identifier recommendations are superseded by UTS #39 rather than cited as current normative profile rules. OriginWeave therefore rejects the reviewed format-character set in roles, shared identifiers, and registry external identifiers, and rejects those same characters inside accessible names while still allowing ordinary U+0020 spaces. @@ -170,6 +172,8 @@ World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Application World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ +World Wide Web Consortium. (2026, August 25). *WebDriver BiDi* (Editor's Draft). https://w3c.github.io/webdriver-bidi/ + World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04695 From 911ea33d8a5aca7673307bb6fdcad4b450f5c111 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 13:14:34 -0700 Subject: [PATCH 534/570] fix(core): make limitation validation monomorphic --- crates/originweave-core/src/release_acceptance.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/originweave-core/src/release_acceptance.rs b/crates/originweave-core/src/release_acceptance.rs index 312344a30..a3655de52 100644 --- a/crates/originweave-core/src/release_acceptance.rs +++ b/crates/originweave-core/src/release_acceptance.rs @@ -97,7 +97,13 @@ impl DeclaredLimitation { unsupported_claim: impl Into, buyer_consequence: impl Into, ) -> Result { - let unsupported_claim = unsupported_claim.into(); + Self::from_owned_text(unsupported_claim.into(), buyer_consequence.into()) + } + + fn from_owned_text( + unsupported_claim: String, + buyer_consequence: String, + ) -> Result { if unsupported_claim.trim().is_empty() { return Err(ReleaseDecisionError::EmptyLimitationClaim); } @@ -117,7 +123,6 @@ impl DeclaredLimitation { { return Err(ReleaseDecisionError::InvalidLimitationClaim); } - let buyer_consequence = buyer_consequence.into(); if buyer_consequence.trim().is_empty() { return Err(ReleaseDecisionError::EmptyLimitationConsequence); } From 730a8f1a793bfba966d5708fdc98ce4c5ca35743 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 05:25:39 +0900 Subject: [PATCH 535/570] docs: refresh exact delivery gap evidence --- CHANGELOG.md | 1 + docs/product-technical-gap-baseline.md | 13 +++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4dd97ce6..d63bdbd07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] ### Added +- Corrected the 2026-08-26 product-gap snapshot with current #229 presentation-identity evidence, stacked-only #205 integration evidence, the 153-PR queue count, and explicit root-versus-child merge ordering. - Refreshed the product and technical gap baseline onto the 2026-08-26 live inventory: 153 open pull requests (39 ready, 114 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 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. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 8c9a0f0b3..ce30b67f0 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -38,10 +38,11 @@ Representative active workstreams at this snapshot were: | Workstream | Representative active PR evidence | Delivery boundary | |---|---|---| | Product baseline | (merged: #196 on 2026-08-24) | Baseline publication reached protected `main`; this document is its successor snapshot | +| Presentation identity | #229 at `585a7d5545b13f18d76f79100ff4d47ac423e861` onto `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | Ready/non-draft local privacy kernel; all observed exact-head checks except Strix passed, but the PR remains blocked and review-required, and no Chromium adapter or protected-main shipment is claimed | | Enterprise approval authority | #220 | Ready/non-draft bounded maker-checker approval lifecycle on the exact `ApprovalScope`; all current-head checks green at snapshot, awaiting current-head review evidence | | Release artifact identity | #218 and #219 | Ready/non-draft fail-closed benchmark release decision and canonical release manifest binding; Strix provider-failure reruns completed green on both heads | | Schema-bound extraction and BAP lifecycle | #209 and #208 | Ready/non-draft schema-bound extraction contract and resumable task-lifecycle kernel; #209 Strix rerun green, #208 rerun re-dispatched after a further provider failure | -| WebDriver BiDi transport | #181 through #205 | Fifteen-deep draft stack from bounded `locateNodes` command serialization through framed exchange over a bounded WebSocket opening path; still no authenticated browser-process provenance, semantic task execution, or protected-main shipment | +| WebDriver BiDi transport | #188 through #205 | Active stack whose top #205 merged into its prerequisite branch, not protected `main`; it exercises framed `locateNodes` exchange over a bounded WebSocket opening path, but authenticated browser-process provenance, semantic task execution, and protected-main shipment remain unproven | | MCP adapter | (#168 merged) and #170 | Typed MCP routing foundations are protected-main behavior since 2026-08-24; conservative `tools/list` cache metadata remains active-PR evidence with a Strix rerun in flight | | Workflow-registry audit | #124 | Real Strix finding vuln-0001 (Unicode homoglyph path confusion, MEDIUM) remediated on head `30cc458b` with regression contract tests; fresh exact-head checks and review re-running | | Controlled Chromium and recovery | #65, #70-#73, #100, #105, #142-#152 and descendants | Real pinned-browser fixture, semantic location, resource, crash, and teardown evidence exists on active stacks; evidence does not transfer across heads or prerequisites | @@ -50,7 +51,7 @@ Representative active workstreams at this snapshot were: | Sensitive-data and model route policy | #10 and its active policy stacks | Deterministic policy values exist, but trusted broker execution, retention/deletion, runtime isolation, and auditable product workflows remain open | | VPN/profile intent | #149 | Bounded WireGuard/IKEv2 profile authority reconciled with main (`54f96008`); it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof | -Draft PR #205 is the current top WebDriver BiDi locate-nodes slice; its opening-path prerequisites #195 and #198 remain draft evidence and cannot be treated as shipped behavior. +PR #205 head `f427aa69151987d7e3369bd96d5739ea38d0f7ad` merged as `6c5ef5e2079d54c617183ecfa757e406f48f0aea` into stacked prerequisite branch `feat/webdriver-bidi-websocket-frame-transport` at base `c1bc7e78f3a9debf4f517fb6b5f11dd67be4ad92`. Its successful exact-head checks are stacked-branch integration evidence only; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`. #### Current exact-head active PR evidence @@ -100,7 +101,7 @@ Phase 1 is **in progress**, not shipped. #195 and #198 provide bounded WebSocket #### #149 VPN/profile intent status -#149 is a ready (non-draft) pull request whose conflict reconciliation and rustfmt correction landed on head `54f96008` on 2026-08-26; it still only describes bounded WireGuard/IKEv2 profile authority and does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof. +PR #149 is a ready (non-draft) pull request whose conflict reconciliation and rustfmt correction landed on head `54f96008` on 2026-08-26; it still only describes bounded WireGuard/IKEv2 profile authority and does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof. The current queue must be processed in dependency order. A green child branch cannot substitute for current checks and review on its prerequisite, synthetic merge, or eventual protected-main commit. PRs that only duplicate, supersede, or preserve stale branch topology should be closed with explicit replacement evidence rather than retained indefinitely; this loop exercised that policy by closing superseded #153 with replacement evidence. @@ -147,7 +148,7 @@ The hourly product-development loop is operational infrastructure, not proof tha | P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | | P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | | P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | -| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 158-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 153-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | ## Commercial completion definition @@ -166,7 +167,7 @@ OriginWeave is not complete merely because every low-level primitive exists in s ## Next executable queue -1. Drain the merge gate in dependency order: for every ready root PR whose current head is check-green with resolved threads, obtain current-head OpenCode-review evidence (approval or authoritative skip), then perform the owner-directed administrative merge permitted by the solo-maintainer hold, and only then retarget each immediate child's base to protected `main` and revalidate it independently. The 2026-08-26 candidates in this class are #37, #40, #43, #45–#48, #51, #62–#65, #74, #82, #124, #149, #152, #156–#166, #170, #173, #175, and #208–#220 as their re-dispatched checks land. +1. Drain the merge gate in dependency order: for every ready root PR whose current head is check-green with resolved threads, obtain current-head OpenCode-review evidence (approval or authoritative skip), then perform the owner-directed administrative merge permitted by the solo-maintainer hold. Root candidates include #37, #40, #43, #45–#48, #51, #62–#65, #74, #82, #124, #149, #152, #156–#166, #170, #173, #175, #208, #209, #218, and #219 as their re-dispatched checks land. Treat dependent children separately: only after a predecessor reaches protected `main`, retarget and independently revalidate its immediate child; preserve orders such as #218 → #221 → #220 rather than treating #208–#220 as a flat merge range. 2. Keep the organization review pipeline healthy: monitor the central Actions backlog recorded above; if OpenCode reviews stop landing on OriginWeave heads while the queue is idle, repair `ContextualWisdomLab/.github` dispatch/concurrency configuration rather than weakening any gate. 3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #181–#205 WebSocket opening path and framed BiDi command/response stack, then semantic observation, policy, action, post-condition, and recovery boundaries on protected `main`. 4. Finish #27 and #10 as separate security tracks; neither should be hidden inside the first browser PR. @@ -342,4 +343,4 @@ done The branch-scoped rules response determines the active rules affecting `main`; each PR's exact `HEAD_SHA` then determines which check runs, legacy statuses, workflow runs, reviews, and unresolved threads are current. The saved merge verdict binds counted approvals to the latest review per eligible collaborator, excludes the PR author, and requires `APPROVED` on the exact head. It deliberately does **not** infer GitHub's actual last-push actor from commit author or committer metadata: when `require_last_push_approval` is active, this portable evidence procedure records `github_rule_evaluation_required` and keeps `approval_gate_satisfied` false until GitHub's authoritative rule evaluation is consulted. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. Evidence is retained only when both `RECHECKED_HEAD_SHA` and `RECHECKED_BASE_SHA` match the collected values; a moving head or base discards the temporary verdict, and three failed attempts leave no unstable merge verdict. -For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. \ No newline at end of file +For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. From 55bb2420f53faa80eb8aeb7839af063a3289ca03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 05:35:04 +0900 Subject: [PATCH 536/570] docs: bind baseline to live merge authority --- CHANGELOG.md | 2 +- docs/product-technical-gap-baseline.md | 18 +++++++++--------- ...ocumentation_active_pr_evidence_contract.py | 11 ++++++----- tests/test_product_completion_gap_contract.py | 8 ++++++++ 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d63bdbd07..0f6ed8d40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] ### Added -- Corrected the 2026-08-26 product-gap snapshot with current #229 presentation-identity evidence, stacked-only #205 integration evidence, the 153-PR queue count, and explicit root-versus-child merge ordering. +- Corrected the 2026-08-26 product-gap snapshot with current #229 presentation-identity evidence, stacked-only #205 integration evidence, current base/head pairs, the 153-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: 153 open pull requests (39 ready, 114 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 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. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index ce30b67f0..762eedac5 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -14,7 +14,7 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, a ### Open pull requests -The live repository contained **153 open pull requests: 39 non-draft and 114 draft** when this snapshot re-paginated the complete open inventory (down from 158/44/114 on 2026-08-24 after supersession closure of #153). The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **153 open pull requests: 39 non-draft and 114 draft** when this snapshot re-paginated the complete open inventory, a net reduction from 158/44/114 on 2026-08-24 amid merged work, supersession closure of #153, and newly opened follow-on work. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. #### 2026-08-26 maintenance-loop record @@ -59,12 +59,12 @@ The following newest slices were re-fetched from GitHub for this snapshot. Their | PR | State | Exact base head | Exact head | |---|---|---|---| -| #220 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `e0740a6f3a41067a4460249378e0266815018a74` | -| #219 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `3e34a54ae279686a28309d59b8b3b9bfbd283a80` | -| #218 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `eac2014bf0e642953bed2c71e5fe963900b22286` | -| #209 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `b35d739017aa5d361b605be48045be504a35f6f` | -| #208 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `e41d3be4c290c4e434aac33d777e511dfb94e03d` | -| #124 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `30cc458b` (post-remediation) | +| #220 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e0740a6f3a41067a4460249378e0266815018a74` | +| #219 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `3e34a54ae279686a28309d59b8b3b9bfbd283a80` | +| #218 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `911ea33d8a5aca7673307bb6fdcad4b450f5c111` | +| #209 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `b35d739017aa5d361b605be48045be50b5a35f6f` | +| #208 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e41d3be4c290c4e434aac33d777e511dfb94e03d` | +| #124 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `296ad25bb541023dbc869ae07ae1d853820f83a4` | These rows are delivery evidence only. None has counted independent approval in the current collaborator inventory, and predecessor rows from earlier snapshots are retained below as regression anchors that must never be promoted to current-head evidence. @@ -109,7 +109,7 @@ The current queue must be processed in dependency order. A green child branch ca The active `CWL Central required workflows` ruleset (re-fetched for this snapshot) requires one approving review, resolved review threads, no last-push approval requirement, `merge`/`squash` merge methods, and seven configured required workflows (`close-empty-pr`, `opencode-review`, `pr-review-merge-scheduler`, `security-scan`, `strix`, `sast-semgrep`, `noema-review`). The current collaborator inventory contains only `seonghobae` with administration and push permissions, creating a **reviewer-provisioning gap** for counted non-author approval. -This gap does not authorize self-approval, stale-head merges, or weaker checks. Under the documented solo-maintainer governance condition the counted-approval rule is on hold rather than manufactured; exact current-head checks, security gates, complete coverage, rustdoc/Clippy, thread resolution, current-head AI-review evidence from the OpenCode reviewer, and branch protection remain mandatory before any owner-directed administrative merge. Before any merge decision, re-fetch the exact ruleset, collaborators, PR head/base, reviews, unresolved threads, and required checks; do not assume this dated observation remains current. +This gap does not authorize self-approval, stale-head merges, administrative bypass, or weaker checks. Because the current GitHub ruleset independently requires a counted approval, the solo-maintainer hold does not satisfy the live merge gate: an eligible non-author collaborator must submit a formal `APPROVED` review on the current head. Until that reviewer-provisioning gap is repaired, protected-main merges stop even when exact-head checks, security gates, complete coverage, rustdoc/Clippy, threads, and AI-review evidence are otherwise complete. Before any merge decision, re-fetch the exact ruleset, collaborators, PR head/base, reviews, unresolved threads, and required checks; do not assume this dated observation remains current. ### Open issues and operational signals @@ -167,7 +167,7 @@ OriginWeave is not complete merely because every low-level primitive exists in s ## Next executable queue -1. Drain the merge gate in dependency order: for every ready root PR whose current head is check-green with resolved threads, obtain current-head OpenCode-review evidence (approval or authoritative skip), then perform the owner-directed administrative merge permitted by the solo-maintainer hold. Root candidates include #37, #40, #43, #45–#48, #51, #62–#65, #74, #82, #124, #149, #152, #156–#166, #170, #173, #175, #208, #209, #218, and #219 as their re-dispatched checks land. Treat dependent children separately: only after a predecessor reaches protected `main`, retarget and independently revalidate its immediate child; preserve orders such as #218 → #221 → #220 rather than treating #208–#220 as a flat merge range. +1. Drain the merge gate in dependency order: for every ready root PR whose current head is check-green with resolved threads, obtain the current ruleset's counted `APPROVED` review from an eligible non-author collaborator; OpenCode approval or skip evidence does not substitute for that GitHub review. If no eligible approver exists, record the reviewer-provisioning gap and do not merge. Root candidates include #37, #40, #43, #45–#48, #51, #62–#65, #74, #82, #124, #149, #152, #156–#166, #170, #173, #175, #208, #209, #218, and #219 as their re-dispatched checks land. Treat dependent children separately: only after a predecessor reaches protected `main`, retarget and independently revalidate its immediate child; preserve orders such as #218 → #221 → #220 rather than treating #208–#220 as a flat merge range. 2. Keep the organization review pipeline healthy: monitor the central Actions backlog recorded above; if OpenCode reviews stop landing on OriginWeave heads while the queue is idle, repair `ContextualWisdomLab/.github` dispatch/concurrency configuration rather than weakening any gate. 3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #181–#205 WebSocket opening path and framed BiDi command/response stack, then semantic observation, policy, action, post-condition, and recovery boundaries on protected `main`. 4. Finish #27 and #10 as separate security tracks; neither should be hidden inside the first browser PR. diff --git a/tests/test_documentation_active_pr_evidence_contract.py b/tests/test_documentation_active_pr_evidence_contract.py index df259d5ae..602ab4760 100644 --- a/tests/test_documentation_active_pr_evidence_contract.py +++ b/tests/test_documentation_active_pr_evidence_contract.py @@ -37,11 +37,12 @@ def test_latest_live_pr_snapshot_is_recorded_in_the_product_baseline(self) -> No """The baseline must preserve exact heads for the newest active product slices.""" for marker in ( "Current exact-head active PR evidence", - "| #220 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `e0740a6f3a41067a4460249378e0266815018a74` |", - "| #219 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `3e34a54ae279686a28309d59b8b3b9bfbd283a80` |", - "| #218 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `eac2014bf0e642953bed2c71e5fe963900b22286` |", - "| #209 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `b35d739017aa5d361b605be48045be504a35f6f` |", - "| #208 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `e41d3be4c290c4e434aac33d777e511dfb94e03d` |", + "| #220 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e0740a6f3a41067a4460249378e0266815018a74` |", + "| #219 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `3e34a54ae279686a28309d59b8b3b9bfbd283a80` |", + "| #218 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `911ea33d8a5aca7673307bb6fdcad4b450f5c111` |", + "| #209 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `b35d739017aa5d361b605be48045be50b5a35f6f` |", + "| #208 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e41d3be4c290c4e434aac33d777e511dfb94e03d` |", + "| #124 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `296ad25bb541023dbc869ae07ae1d853820f83a4` |", ): with self.subTest(marker=marker): self.assertIn(marker, self.baseline) diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index ecf749c1b..cd7256b84 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -48,6 +48,14 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: with self.subTest(stale_phrase=stale_phrase): self.assertNotIn(stale_phrase, text) + def test_active_github_approval_rule_is_not_documented_as_bypassable(self) -> None: + """An active counted-approval rule must stop merge without an eligible approver.""" + text = BASELINE.read_text(encoding="utf-8") + + self.assertIn("eligible non-author", text) + self.assertIn("reviewer-provisioning gap", text) + self.assertNotIn("owner-directed administrative merge", text) + def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> None: """The evidence procedure must paginate the queue and inspect each exact PR head.""" text = BASELINE.read_text(encoding="utf-8") From d881d6a40aa5a95a304566c8b241560ed0dffdcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 05:51:41 +0900 Subject: [PATCH 537/570] docs: refresh dependency queue evidence --- CHANGELOG.md | 2 ++ docs/product-technical-gap-baseline.md | 4 ++-- tests/test_product_completion_gap_contract.py | 7 +++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f6ed8d40..1118bc012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Refreshed the product-gap queue to 150 open pull requests (44 ready, 106 draft) after #189, #191, and #193 were merged into their immediate stacked prerequisites; these are dependency-consolidation results, not protected-main shipment. + ### Added - Corrected the 2026-08-26 product-gap snapshot with current #229 presentation-identity evidence, stacked-only #205 integration evidence, current base/head pairs, the 153-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: 153 open pull requests (39 ready, 114 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. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 762eedac5..a65decf2b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -14,7 +14,7 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, a ### Open pull requests -The live repository contained **153 open pull requests: 39 non-draft and 114 draft** when this snapshot re-paginated the complete open inventory, a net reduction from 158/44/114 on 2026-08-24 amid merged work, supersession closure of #153, and newly opened follow-on work. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **150 open pull requests: 44 non-draft and 106 draft** when this snapshot re-paginated the complete open inventory. The queue fell from 153 after #189, #191, and #193 were merged into their immediate stacked prerequisites; those merges are dependency consolidation, not protected-main delivery. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. #### 2026-08-26 maintenance-loop record @@ -148,7 +148,7 @@ The hourly product-development loop is operational infrastructure, not proof tha | P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | | P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | | P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | -| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 153-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 150-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | ## Commercial completion definition diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index cd7256b84..382ee4b24 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -17,9 +17,9 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: text = BASELINE.read_text(encoding="utf-8") for phrase in ( - "153 open pull requests", - "39 non-draft", - "114 draft", + "150 open pull requests", + "44 non-draft", + "106 draft", "#198", "#199", "#200", @@ -41,7 +41,6 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: "78 draft", "148 open pull requests", "79 draft PRs", - "150 open pull requests", "40 non-draft", "110 draft", ): From dfa617740b2ad207839cadbafe02c8094261e46f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:28:50 +0900 Subject: [PATCH 538/570] docs: refresh product gap queue --- CHANGELOG.md | 2 +- docs/product-technical-gap-baseline.md | 4 ++-- tests/test_documentation_active_pr_evidence_contract.py | 4 ++-- tests/test_product_completion_gap_contract.py | 7 ++++--- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1118bc012..bf85d62ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] -- Refreshed the product-gap queue to 150 open pull requests (44 ready, 106 draft) after #189, #191, and #193 were merged into their immediate stacked prerequisites; these are dependency-consolidation results, not protected-main shipment. +- Refreshed the product-gap queue to 140 open pull requests (48 ready, 92 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, and #117 were merged into their immediate stacked prerequisites; these are dependency-consolidation results, not protected-main shipment. ### Added - Corrected the 2026-08-26 product-gap snapshot with current #229 presentation-identity evidence, stacked-only #205 integration evidence, current base/head pairs, the 153-PR queue count, explicit root-versus-child merge ordering, and the active GitHub counted-approval gate. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index a65decf2b..c3a22ddc1 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -14,7 +14,7 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, a ### Open pull requests -The live repository contained **150 open pull requests: 44 non-draft and 106 draft** when this snapshot re-paginated the complete open inventory. The queue fell from 153 after #189, #191, and #193 were merged into their immediate stacked prerequisites; those merges are dependency consolidation, not protected-main delivery. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **140 open pull requests: 48 non-draft and 92 draft** when this snapshot re-paginated the complete open inventory. Since the prior 150-PR snapshot, #190, #188, #185, #192, #182, #184, #115, #181, #116, and #117 were merged into their immediate stacked prerequisites; those merges are dependency consolidation, not protected-main delivery. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. #### 2026-08-26 maintenance-loop record @@ -148,7 +148,7 @@ The hourly product-development loop is operational infrastructure, not proof tha | P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | | P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | | P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | -| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 150-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 140-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | ## Commercial completion definition diff --git a/tests/test_documentation_active_pr_evidence_contract.py b/tests/test_documentation_active_pr_evidence_contract.py index 602ab4760..ad1a8a936 100644 --- a/tests/test_documentation_active_pr_evidence_contract.py +++ b/tests/test_documentation_active_pr_evidence_contract.py @@ -54,8 +54,8 @@ def test_baseline_refresh_changelog_matches_the_live_snapshot(self) -> None: changed = self.changelog.split("### Changed", 1)[1].split("### Security", 1)[0] self.assertIn(refresh, added) self.assertNotIn(refresh, changed) - self.assertIn("150 open pull requests, 110 drafts", self.changelog) - self.assertNotIn("150 open pull requests, 112 drafts", self.changelog) + self.assertIn("140 open pull requests (48 ready, 92 draft)", self.changelog) + self.assertNotIn("150 open pull requests (44 ready, 106 draft)", added) def test_dependency_stacks_are_explicit_and_non_shipped(self) -> None: """Current browser, network, sensitive and compatibility stacks stay active-only.""" diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 382ee4b24..3a5e6eda6 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -17,9 +17,9 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: text = BASELINE.read_text(encoding="utf-8") for phrase in ( - "150 open pull requests", - "44 non-draft", - "106 draft", + "140 open pull requests", + "48 non-draft", + "92 draft", "#198", "#199", "#200", @@ -43,6 +43,7 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: "79 draft PRs", "40 non-draft", "110 draft", + "150 open pull requests", ): with self.subTest(stale_phrase=stale_phrase): self.assertNotIn(stale_phrase, text) From 54f2edc59a06fe7e6ba2f1eb10a4291c66eefff5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:56:45 +0900 Subject: [PATCH 539/570] docs: refresh live product-gap queue evidence --- CHANGELOG.md | 2 +- docs/product-technical-gap-baseline.md | 4 ++-- tests/test_documentation_active_pr_evidence_contract.py | 2 +- tests/test_product_completion_gap_contract.py | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf85d62ab..9a3bb43c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] -- Refreshed the product-gap queue to 140 open pull requests (48 ready, 92 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, and #117 were merged into their immediate stacked prerequisites; these are dependency-consolidation results, not protected-main shipment. +- Refreshed the product-gap queue to 130 open pull requests (44 ready, 86 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, and #111 were merged into their immediate stacked prerequisites, and #113 moved to ready; these are dependency-consolidation results, not protected-main shipment. ### Added - Corrected the 2026-08-26 product-gap snapshot with current #229 presentation-identity evidence, stacked-only #205 integration evidence, current base/head pairs, the 153-PR queue count, explicit root-versus-child merge ordering, and the active GitHub counted-approval gate. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c3a22ddc1..fd6d7bd2a 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -14,7 +14,7 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, a ### Open pull requests -The live repository contained **140 open pull requests: 48 non-draft and 92 draft** when this snapshot re-paginated the complete open inventory. Since the prior 150-PR snapshot, #190, #188, #185, #192, #182, #184, #115, #181, #116, and #117 were merged into their immediate stacked prerequisites; those merges are dependency consolidation, not protected-main delivery. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **130 open pull requests: 44 non-draft and 86 draft** when this snapshot re-paginated the complete open inventory. Since the prior 150-PR snapshot, #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, and #111 were merged into their immediate stacked prerequisites, and #113 moved to ready. Those transitions are dependency consolidation, not protected-main delivery; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`, with 13 open issues and no releases or tags. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. #### 2026-08-26 maintenance-loop record @@ -148,7 +148,7 @@ The hourly product-development loop is operational infrastructure, not proof tha | P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | | P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | | P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | -| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 140-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 130-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | ## Commercial completion definition diff --git a/tests/test_documentation_active_pr_evidence_contract.py b/tests/test_documentation_active_pr_evidence_contract.py index ad1a8a936..4b62ad087 100644 --- a/tests/test_documentation_active_pr_evidence_contract.py +++ b/tests/test_documentation_active_pr_evidence_contract.py @@ -54,7 +54,7 @@ def test_baseline_refresh_changelog_matches_the_live_snapshot(self) -> None: changed = self.changelog.split("### Changed", 1)[1].split("### Security", 1)[0] self.assertIn(refresh, added) self.assertNotIn(refresh, changed) - self.assertIn("140 open pull requests (48 ready, 92 draft)", self.changelog) + self.assertIn("130 open pull requests (44 ready, 86 draft)", self.changelog) self.assertNotIn("150 open pull requests (44 ready, 106 draft)", added) def test_dependency_stacks_are_explicit_and_non_shipped(self) -> None: diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 3a5e6eda6..10773a625 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -17,9 +17,9 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: text = BASELINE.read_text(encoding="utf-8") for phrase in ( - "140 open pull requests", - "48 non-draft", - "92 draft", + "130 open pull requests", + "44 non-draft", + "86 draft", "#198", "#199", "#200", From 1ee95f254d57ae85223e4c02cb69f18e4a12eaf0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 07:12:22 +0900 Subject: [PATCH 540/570] docs: record consolidated live PR queue --- CHANGELOG.md | 2 +- docs/product-technical-gap-baseline.md | 4 ++-- tests/test_documentation_active_pr_evidence_contract.py | 2 +- tests/test_product_completion_gap_contract.py | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a3bb43c5..37970b72d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] -- Refreshed the product-gap queue to 130 open pull requests (44 ready, 86 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, and #111 were merged into their immediate stacked prerequisites, and #113 moved to ready; these are dependency-consolidation results, not protected-main shipment. +- Refreshed the product-gap queue to 128 open pull requests (54 ready, 74 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review; these are queue-consolidation results, not protected-main shipment. ### Added - Corrected the 2026-08-26 product-gap snapshot with current #229 presentation-identity evidence, stacked-only #205 integration evidence, current base/head pairs, the 153-PR queue count, explicit root-versus-child merge ordering, and the active GitHub counted-approval gate. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index fd6d7bd2a..4bd48a2d6 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -14,7 +14,7 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, a ### Open pull requests -The live repository contained **130 open pull requests: 44 non-draft and 86 draft** when this snapshot re-paginated the complete open inventory. Since the prior 150-PR snapshot, #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, and #111 were merged into their immediate stacked prerequisites, and #113 moved to ready. Those transitions are dependency consolidation, not protected-main delivery; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`, with 13 open issues and no releases or tags. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **128 open pull requests: 54 non-draft and 74 draft** when this snapshot re-paginated the complete open inventory. Since the prior 150-PR snapshot, #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review. Those transitions are queue consolidation, not protected-main delivery; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`, with 13 open issues and no releases or tags. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. #### 2026-08-26 maintenance-loop record @@ -148,7 +148,7 @@ The hourly product-development loop is operational infrastructure, not proof tha | P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | | P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | | P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | -| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 130-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 128-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | ## Commercial completion definition diff --git a/tests/test_documentation_active_pr_evidence_contract.py b/tests/test_documentation_active_pr_evidence_contract.py index 4b62ad087..fbeeb62d6 100644 --- a/tests/test_documentation_active_pr_evidence_contract.py +++ b/tests/test_documentation_active_pr_evidence_contract.py @@ -54,7 +54,7 @@ def test_baseline_refresh_changelog_matches_the_live_snapshot(self) -> None: changed = self.changelog.split("### Changed", 1)[1].split("### Security", 1)[0] self.assertIn(refresh, added) self.assertNotIn(refresh, changed) - self.assertIn("130 open pull requests (44 ready, 86 draft)", self.changelog) + self.assertIn("128 open pull requests (54 ready, 74 draft)", self.changelog) self.assertNotIn("150 open pull requests (44 ready, 106 draft)", added) def test_dependency_stacks_are_explicit_and_non_shipped(self) -> None: diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 10773a625..8a1f54c1f 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -17,9 +17,9 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: text = BASELINE.read_text(encoding="utf-8") for phrase in ( - "130 open pull requests", - "44 non-draft", - "86 draft", + "128 open pull requests", + "54 non-draft", + "74 draft", "#198", "#199", "#200", From e01c8b2f65767920176f3a120058bd3ebedd0d65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 15:23:05 -0700 Subject: [PATCH 541/570] test(docs): pin current gap inventory snapshot --- ...test_gap_snapshot_inventory_consistency.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/test_gap_snapshot_inventory_consistency.py diff --git a/tests/test_gap_snapshot_inventory_consistency.py b/tests/test_gap_snapshot_inventory_consistency.py new file mode 100644 index 000000000..0daca1f85 --- /dev/null +++ b/tests/test_gap_snapshot_inventory_consistency.py @@ -0,0 +1,58 @@ +"""Regression contracts for the current dated product-gap inventory snapshot.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +BASELINE = ROOT / "docs" / "product-technical-gap-baseline.md" +CHANGELOG = ROOT / "CHANGELOG.md" + + +class GapSnapshotInventoryConsistencyTests(unittest.TestCase): + """Prevent one dated snapshot from carrying contradictory live PR totals.""" + + @classmethod + def setUpClass(cls) -> None: + cls.baseline = BASELINE.read_text(encoding="utf-8") + cls.changelog = CHANGELOG.read_text(encoding="utf-8") + + def test_current_baseline_inventory_matches_the_verified_snapshot(self) -> None: + """The current snapshot must use the exact 126/54/72 inventory observation.""" + current = self.baseline.split("### Open pull requests", 1)[1].split( + "#### 2026-08-26 maintenance-loop record", 1 + )[0] + for marker in ( + "126 open pull requests", + "54 non-draft", + "72 draft", + ): + with self.subTest(marker=marker): + self.assertIn(marker, current) + + for stale in ( + "128 open pull requests", + "74 draft", + "153 open pull requests", + "114 draft", + ): + with self.subTest(stale=stale): + self.assertNotIn(stale, current) + + def test_unreleased_changelog_uses_one_current_inventory(self) -> None: + """The Unreleased current snapshot must agree before and inside Added.""" + unreleased = self.changelog.split("## [Unreleased]", 1)[1] + preamble, remainder = unreleased.split("### Added", 1) + added = remainder.split("### Changed", 1)[0] + + expected = "126 open pull requests (54 ready, 72 draft)" + self.assertIn(expected, preamble) + self.assertIn(expected, added) + self.assertNotIn("128 open pull requests (54 ready, 74 draft)", preamble) + self.assertNotIn("153 open pull requests (39 ready, 114 draft)", added) + + +if __name__ == "__main__": + unittest.main() From 27040a378c3baf796deb9efa2fea366ff096e85c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 15:40:14 -0700 Subject: [PATCH 542/570] test(docs): pin live 126 PR snapshot --- tests/test_product_completion_gap_contract.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 8a1f54c1f..de5c1ad6c 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -17,9 +17,9 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: text = BASELINE.read_text(encoding="utf-8") for phrase in ( - "128 open pull requests", + "126 open pull requests", "54 non-draft", - "74 draft", + "72 draft", "#198", "#199", "#200", @@ -44,6 +44,8 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: "40 non-draft", "110 draft", "150 open pull requests", + "128 open pull requests", + "74 draft", ): with self.subTest(stale_phrase=stale_phrase): self.assertNotIn(stale_phrase, text) From b4a20af46b1d24f29f53647695e67c46e590c040 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 15:40:52 -0700 Subject: [PATCH 543/570] test(docs): reject superseded inventory totals --- tests/test_documentation_active_pr_evidence_contract.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_documentation_active_pr_evidence_contract.py b/tests/test_documentation_active_pr_evidence_contract.py index fbeeb62d6..bc60535a2 100644 --- a/tests/test_documentation_active_pr_evidence_contract.py +++ b/tests/test_documentation_active_pr_evidence_contract.py @@ -54,8 +54,9 @@ def test_baseline_refresh_changelog_matches_the_live_snapshot(self) -> None: changed = self.changelog.split("### Changed", 1)[1].split("### Security", 1)[0] self.assertIn(refresh, added) self.assertNotIn(refresh, changed) - self.assertIn("128 open pull requests (54 ready, 74 draft)", self.changelog) - self.assertNotIn("150 open pull requests (44 ready, 106 draft)", added) + self.assertIn("126 open pull requests (54 ready, 72 draft)", self.changelog) + self.assertNotIn("128 open pull requests (54 ready, 74 draft)", added) + self.assertNotIn("153 open pull requests (39 ready, 114 draft)", added) def test_dependency_stacks_are_explicit_and_non_shipped(self) -> None: """Current browser, network, sensitive and compatibility stacks stay active-only.""" From 8d66fe716be829a877fe0bb8b78f846e4c2f16da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 15:42:16 -0700 Subject: [PATCH 544/570] docs: align gap baseline to verified 126 PR inventory --- docs/product-technical-gap-baseline.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 4bd48a2d6..34f70350b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -14,7 +14,7 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, a ### Open pull requests -The live repository contained **128 open pull requests: 54 non-draft and 74 draft** when this snapshot re-paginated the complete open inventory. Since the prior 150-PR snapshot, #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review. Those transitions are queue consolidation, not protected-main delivery; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`, with 13 open issues and no releases or tags. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **126 open pull requests: 54 non-draft and 72 draft** when this snapshot re-paginated the complete open inventory. Since the prior 150-PR snapshot, #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review. Those transitions are queue consolidation, not protected-main delivery; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`, with 13 open issues and no releases or tags. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. #### 2026-08-26 maintenance-loop record @@ -148,7 +148,7 @@ The hourly product-development loop is operational infrastructure, not proof tha | P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | | P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | | P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | -| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 128-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 126-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | ## Commercial completion definition From e5ad7e7199d25c8c02b181ec4f36a72b5a6a719a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 15:42:58 -0700 Subject: [PATCH 545/570] docs: reconcile changelog with live inventory snapshot --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37970b72d..dab265478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] -- Refreshed the product-gap queue to 128 open pull requests (54 ready, 74 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review; these are queue-consolidation results, not protected-main shipment. +- Refreshed the product-gap queue to 126 open pull requests (54 ready, 72 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review; these are queue-consolidation results, not protected-main shipment. ### Added -- Corrected the 2026-08-26 product-gap snapshot with current #229 presentation-identity evidence, stacked-only #205 integration evidence, current base/head pairs, the 153-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: 153 open pull requests (39 ready, 114 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. +- 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 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. - Added a dated product and technical gap baseline that separates protected-main implementation truth, active pull-request evidence, live review/check blockers, and the next buyer-visible Phase 1 acceptance work. From 1116db293f382596e8029b1222f65a78f6a33431 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 15:45:24 -0700 Subject: [PATCH 546/570] test(docs): pin immediate 158 PR predecessor snapshot --- tests/test_product_completion_gap_contract.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index de5c1ad6c..1c24fe674 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -20,6 +20,7 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: "126 open pull requests", "54 non-draft", "72 draft", + "2026-08-24 158-PR snapshot", "#198", "#199", "#200", @@ -44,6 +45,7 @@ def test_baseline_records_current_inventory_and_completion_issues(self) -> None: "40 non-draft", "110 draft", "150 open pull requests", + "prior 150-PR snapshot", "128 open pull requests", "74 draft", ): From 43fd0bec7a0e0a8d272affa41dd0cbf26efbbd08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 15:47:16 -0700 Subject: [PATCH 547/570] docs: reconcile snapshot predecessor chronology --- docs/product-technical-gap-baseline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 34f70350b..8a702c75f 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -14,7 +14,7 @@ This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, a ### Open pull requests -The live repository contained **126 open pull requests: 54 non-draft and 72 draft** when this snapshot re-paginated the complete open inventory. Since the prior 150-PR snapshot, #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review. Those transitions are queue consolidation, not protected-main delivery; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`, with 13 open issues and no releases or tags. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The live repository contained **126 open pull requests: 54 non-draft and 72 draft** when this snapshot re-paginated the complete open inventory. Compared with the prior **2026-08-24 158-PR snapshot**, the current inventory is 32 PRs smaller. Intervening queue consolidation includes #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 being merged into their immediate stacked prerequisites, while PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review. Those transitions are queue consolidation, not protected-main delivery; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`, with 13 open issues and no releases or tags. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. #### 2026-08-26 maintenance-loop record From c18f9ead05e7b46b151941299d57a8166bea786c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 21:16:37 -0700 Subject: [PATCH 548/570] test(network): classify ambiguous opening write recovery --- .../opening_write_recovery_disposition.rs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 crates/originweave-network/tests/opening_write_recovery_disposition.rs diff --git a/crates/originweave-network/tests/opening_write_recovery_disposition.rs b/crates/originweave-network/tests/opening_write_recovery_disposition.rs new file mode 100644 index 000000000..e3418f5f8 --- /dev/null +++ b/crates/originweave-network/tests/opening_write_recovery_disposition.rs @@ -0,0 +1,55 @@ +use std::io; + +use originweave_network::{ + WebDriverBiDiWebSocketOpeningWriteError, + WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition, +}; + +#[test] +fn complete_or_cleanup_failed_opening_write_requires_reconciliation_before_retry() { + let completed_after_deadline = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 128 }; + assert_eq!( + completed_after_deadline.recovery_disposition(128), + WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::ReconciliationRequired + ); + + let cleanup_failed = WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 128, + source: io::Error::from(io::ErrorKind::InvalidInput), + }; + assert_eq!( + cleanup_failed.recovery_disposition(128), + WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::ReconciliationRequired + ); +} + +#[test] +fn partial_or_inconsistent_opening_write_failure_stays_fail_closed() { + let partial_deadline = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 64 }; + assert_eq!( + partial_deadline.recovery_disposition(128), + WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::RevalidateBeforeNewAttempt + ); + + let partial_timeout = WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 64, + source: io::Error::from(io::ErrorKind::TimedOut), + }; + assert_eq!( + partial_timeout.recovery_disposition(128), + WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::RevalidateBeforeNewAttempt + ); + + let impossible_count = + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 129 }; + assert_eq!( + impossible_count.recovery_disposition(128), + WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::ReconciliationRequired + ); + assert_eq!( + partial_deadline.recovery_disposition(0), + WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::ReconciliationRequired + ); +} From 2d5c0f555602e953230929b4f414c3408c4c071a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 21:18:53 -0700 Subject: [PATCH 549/570] test(network): format opening write recovery regression --- .../tests/opening_write_recovery_disposition.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/originweave-network/tests/opening_write_recovery_disposition.rs b/crates/originweave-network/tests/opening_write_recovery_disposition.rs index e3418f5f8..67c8257d0 100644 --- a/crates/originweave-network/tests/opening_write_recovery_disposition.rs +++ b/crates/originweave-network/tests/opening_write_recovery_disposition.rs @@ -1,8 +1,7 @@ use std::io; use originweave_network::{ - WebDriverBiDiWebSocketOpeningWriteError, - WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition, + WebDriverBiDiWebSocketOpeningWriteError, WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition, }; #[test] From bca92a9f91d90b07f42afa6623dce69be770b918 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 21:21:50 -0700 Subject: [PATCH 550/570] fix(network): classify ambiguous opening write recovery --- crates/originweave-network/src/lib.rs | 53 +++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 59902999d..1a38047df 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -59,3 +59,56 @@ pub use webdriver_bidi_websocket_handshake_raw::{ WebDriverBiDiWebSocketOpeningWriteError, }; pub use webdriver_bidi_websocket_mask_key::WebDriverBiDiWebSocketMaskKey; + +/// Required recovery posture after a failed WebDriver BiDi WebSocket opening-request write. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition { + /// No complete opening request is known to have been submitted. + /// + /// This is not automatic retry permission. The caller must obtain fresh authority, route, + /// connection, and deadline validation before starting another opening request. + RevalidateBeforeNewAttempt, + /// The peer may already have received the complete opening request, or byte accounting is + /// inconsistent with the exact serialized request length. + /// + /// Blind redispatch is forbidden until the caller reconciles the potentially completed + /// external side effect. + ReconciliationRequired, +} + +impl WebDriverBiDiWebSocketOpeningWriteError { + /// Classify the fail-closed recovery posture for this failed opening-request write. + /// + /// `request_byte_count` must be the exact serialized length of the request whose write produced + /// this error. A zero request length, complete-or-greater byte count, or timeout-cleanup failure + /// is treated as ambiguous external completion and therefore requires reconciliation. + #[must_use] + pub fn recovery_disposition( + &self, + request_byte_count: usize, + ) -> WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition { + use WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::{ + ReconciliationRequired, RevalidateBeforeNewAttempt, + }; + + if request_byte_count == 0 { + return ReconciliationRequired; + } + + match self { + Self::InvalidWriteTimeout { .. } => RevalidateBeforeNewAttempt, + Self::WriteTimeoutCleanupFailed { .. } => ReconciliationRequired, + Self::WriteDeadlineExceeded { bytes_written } + | Self::WriteTimeoutConfigurationFailed { bytes_written, .. } + | Self::WriteTimedOut { bytes_written, .. } + | Self::WriteZero { bytes_written } + | Self::WriteFailed { bytes_written, .. } => { + if *bytes_written >= request_byte_count { + ReconciliationRequired + } else { + RevalidateBeforeNewAttempt + } + } + } + } +} From 05e440948840afff1dc6e62cdb6fa52e03ebdaa9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 22:08:15 -0700 Subject: [PATCH 551/570] test(network): cover opening write recovery disposition --- crates/originweave-network/src/lib.rs | 93 +++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 1a38047df..7edf7132f 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -112,3 +112,96 @@ impl WebDriverBiDiWebSocketOpeningWriteError { } } } + +#[cfg(test)] +mod opening_write_recovery_tests { + use std::{io, time::Duration}; + + use super::{ + WebDriverBiDiWebSocketOpeningWriteError, + WebDriverBiDiWebSocketOpeningWriteRecoveryDisposition::{ + ReconciliationRequired, RevalidateBeforeNewAttempt, + }, + }; + + #[test] + fn ambiguous_or_complete_opening_writes_require_reconciliation() { + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 16 } + .recovery_disposition(16), + ReconciliationRequired + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 17, + source: io::Error::other("write completion accounting exceeded request length"), + } + .recovery_disposition(16), + ReconciliationRequired + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutCleanupFailed { + bytes_written: 16, + source: io::Error::other("write timeout cleanup failed after request completion"), + } + .recovery_disposition(16), + ReconciliationRequired + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout: Duration::ZERO, + maximum_timeout: Duration::from_secs(5), + } + .recovery_disposition(0), + ReconciliationRequired + ); + } + + #[test] + fn incomplete_opening_writes_require_fresh_revalidation_before_another_attempt() { + let request_byte_count = 16; + + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::InvalidWriteTimeout { + write_timeout: Duration::ZERO, + maximum_timeout: Duration::from_secs(5), + } + .recovery_disposition(request_byte_count), + RevalidateBeforeNewAttempt + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteDeadlineExceeded { bytes_written: 3 } + .recovery_disposition(request_byte_count), + RevalidateBeforeNewAttempt + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimeoutConfigurationFailed { + bytes_written: 3, + source: io::Error::other("timeout configuration failed"), + } + .recovery_disposition(request_byte_count), + RevalidateBeforeNewAttempt + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteTimedOut { + bytes_written: 3, + source: io::Error::from(io::ErrorKind::TimedOut), + } + .recovery_disposition(request_byte_count), + RevalidateBeforeNewAttempt + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteZero { bytes_written: 3 } + .recovery_disposition(request_byte_count), + RevalidateBeforeNewAttempt + ); + assert_eq!( + WebDriverBiDiWebSocketOpeningWriteError::WriteFailed { + bytes_written: 3, + source: io::Error::other("write failed before request completion"), + } + .recovery_disposition(request_byte_count), + RevalidateBeforeNewAttempt + ); + } +} From 5c111d0db6c363f9d1786c21cc01c5c7398007bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 20:14:14 -0700 Subject: [PATCH 552/570] fix(stack): restore opening-write prerequisite tree --- .github/dependabot.yml | 7 - .../workflows/apply-rust-nightly-refresh.yml | 57 - .github/workflows/ci.yml | 8 +- .../workflows/hourly-product-development.yml | 4 +- ARCHITECTURE.md | 3 +- CHANGELOG.md | 58 +- Cargo.lock | 31 - Cargo.toml | 1 - README.md | 8 +- crates/originweave-bap/Cargo.toml | 12 - crates/originweave-bap/src/lib.rs | 329 ----- .../originweave-bap/tests/task_lifecycle.rs | 253 ---- .../tests/task_lifecycle_recovery.rs | 142 -- crates/originweave-core/Cargo.toml | 4 - .../src/browser_authority_registry.rs | 163 +++ .../originweave-core/src/browser_protocol.rs | 586 ++++++++ .../src/browser_protocol_dispatch.rs | 365 +++++ .../src/browser_protocol_operation.rs | 610 +++++++++ .../originweave-core/src/browser_registry.rs | 864 ++++++++++++ .../src/browser_registry_coverage.rs | 60 + crates/originweave-core/src/contracts.rs | 1065 +++++++++++++++ crates/originweave-core/src/lib.rs | 1182 ++--------------- crates/originweave-core/src/mcp.rs | 478 ------- .../src/release_acceptance.rs | 368 ----- crates/originweave-core/src/root.rs | 17 - .../src/webdriver_bidi_command.rs | 410 ++++++ .../src/webdriver_bidi_error_code.rs | 170 +++ .../src/webdriver_bidi_response_document.rs | 98 ++ ...iver_bidi_response_document_correlation.rs | 269 ++++ .../locate_nodes_result_document.rs | 714 ++++++++++ .../src/webdriver_bidi_response_envelope.rs | 625 +++++++++ .../src/webdriver_bidi_result.rs | 188 +++ ...webdriver_bidi_websocket_connect_target.rs | 206 +++ .../src/webdriver_bidi_websocket_endpoint.rs | 327 +++++ .../tests/browser_authority_registry.rs | 393 ++++++ .../tests/browser_context_origin_binding.rs | 158 +++ ..._context_origin_epoch_protocol_dispatch.rs | 214 +++ ...rowser_context_origin_protocol_dispatch.rs | 180 +++ .../browser_context_origin_revalidation.rs | 97 ++ .../browser_context_protocol_dispatch.rs | 234 ++++ .../tests/browser_protocol_adapter.rs | 372 ++++++ ...rowser_protocol_runtime_adapter_version.rs | 94 ++ .../browser_protocol_runtime_dispatch.rs | 121 ++ .../browser_protocol_runtime_revision.rs | 96 ++ .../tests/browser_protocol_use_validation.rs | 190 +++ ...owser_typed_operation_protocol_dispatch.rs | 150 +++ .../tests/extension_authority.rs | 119 +- .../tests/mcp_authority_route.rs | 362 ----- .../tests/mcp_tools_list_cache.rs | 221 --- .../tests/origin_port_syntax.rs | 18 - .../tests/protocol_version_parsing.rs | 59 + .../protocol_version_runtime_coverage.rs | 12 + .../tests/release_acceptance.rs | 397 ------ .../release_acceptance_canonical_text.rs | 116 -- ...elease_acceptance_meaningful_limitation.rs | 46 - .../release_acceptance_resource_bounds.rs | 98 -- .../tests/release_acceptance_unicode17.rs | 121 -- .../webdriver_bidi_accessibility_query.rs | 195 +++ .../webdriver_bidi_locate_nodes_admission.rs | 289 ++++ .../webdriver_bidi_locate_nodes_atomicity.rs | 88 ++ .../webdriver_bidi_locate_nodes_command.rs | 121 ++ ..._bidi_locate_nodes_response_correlation.rs | 70 + ...ver_bidi_locate_nodes_response_document.rs | 100 ++ ...ver_bidi_locate_nodes_response_envelope.rs | 141 ++ ...iver_bidi_locate_nodes_result_admission.rs | 297 +++++ ...webdriver_bidi_locate_nodes_wire_result.rs | 209 +++ ...driver_bidi_protocol_error_preservation.rs | 44 + .../webdriver_bidi_protocol_kind_admission.rs | 75 ++ .../webdriver_bidi_query_nodes_admission.rs | 365 +++++ .../webdriver_bidi_remote_node_reference.rs | 110 ++ ...webdriver_bidi_response_document_budget.rs | 98 ++ ...er_bidi_response_envelope_failure_edges.rs | 72 + ...ver_bidi_response_envelope_hostile_json.rs | 107 ++ ...webdriver_bidi_response_envelope_parser.rs | 254 ++++ .../webdriver_bidi_response_error_code.rs | 130 ++ ...river_bidi_response_error_code_evidence.rs | 35 + ...webdriver_bidi_socket_peer_verification.rs | 107 ++ ...webdriver_bidi_websocket_connect_target.rs | 99 ++ .../webdriver_bidi_websocket_endpoint.rs | 206 +++ ...iver_bidi_websocket_session_correlation.rs | 92 ++ .../webdriver_bidi_wire_authority_binding.rs | 156 +++ crates/originweave-destination/src/lib.rs | 3 +- crates/originweave-destination/src/proxy.rs | 3 - .../originweave-destination/src/resolution.rs | 236 ---- .../tests/proxy_port_syntax.rs | 29 - .../tests/resolution_freshness.rs | 235 ---- .../resolution_post_expiry_revalidation.rs | 78 -- .../src/extraction_schema.rs | 297 ----- crates/originweave-evidence/src/lib.rs | 113 +- .../src/sensitive_access.rs | 5 +- .../src/sensitive_handle_lifecycle.rs | 144 -- .../browser_protocol_validation_evidence.rs | 83 ++ crates/originweave-evidence/tests/evidence.rs | 4 - .../tests/extraction_normalization.rs | 77 -- .../tests/extraction_schema.rs | 326 ----- .../tests/extraction_schema_error_contract.rs | 48 - .../tests/extraction_source_channel_set.rs | 40 - .../tests/sensitive_handle_access_binding.rs | 114 -- .../sensitive_handle_lifecycle_evidence.rs | 142 -- .../src/webdriver_bidi_connection.rs | 254 ++++ .../src/webdriver_bidi_connection/error.rs | 134 ++ .../src/webdriver_bidi_connection/tests.rs | 361 +++++ .../tests/webdriver_bidi_tcp_connection.rs | 94 ++ crates/originweave-policy/src/lib.rs | 20 - .../tests/extension_mutation_isolation.rs | 343 ----- .../tests/extension_policy_isolation.rs | 215 --- .../tests/extension_secret_isolation.rs | 96 -- .../tests/mcp_route_binding.rs | 96 -- crates/originweave-resource/src/lib.rs | 15 - .../tests/error_contract.rs | 21 - crates/originweave-tls/src/lib.rs | 2 - crates/originweave-tls/src/revocation.rs | 174 --- crates/originweave-tls/src/trust.rs | 1 - .../originweave-tls/tests/policy_contract.rs | 2 +- .../tests/revocation_freshness.rs | 119 -- docs/API_CONTRACT.md | 2 + docs/README.md | 9 - docs/TRD.md | 2 +- ...10-session-context-bound-node-authority.md | 1 + .../0013-manifest-v3-extension-authority.md | 2 +- docs/adr/0016-bap-task-lifecycle-authority.md | 123 -- docs/adr/0106-provenance-evidence-model.md | 20 +- .../0107-browser-protocol-adapter-strategy.md | 20 +- docs/adr/README.md | 10 - docs/doctoring.md | 46 +- docs/doctoring/browser-agent-protocols.md | 39 +- docs/doctoring/rust-toolchain-freshness.md | 44 - docs/product-roadmap.md | 1 + docs/product-technical-gap-baseline.md | 346 ----- .../extension-authority-security.md | 6 - docs/traceability/mcp-authority-route.md | 58 - tests/fixtures/agent_task_basic/index.html | 42 - tests/test_agent_task_fixture_contract.py | 137 -- tests/test_doctoring_reference_contract.py | 28 - ...cumentation_active_pr_evidence_contract.py | 29 - ...test_gap_snapshot_inventory_consistency.py | 58 - tests/test_product_completion_gap_contract.py | 123 -- tests/test_product_documentation_contract.py | 49 - tests/test_repository_contract.py | 4 +- tests/test_rust_toolchain_contract.py | 53 - ...ebdriver_bidi_connect_target_governance.py | 35 + 141 files changed, 13766 insertions(+), 8455 deletions(-) delete mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/apply-rust-nightly-refresh.yml delete mode 100644 crates/originweave-bap/Cargo.toml delete mode 100644 crates/originweave-bap/src/lib.rs delete mode 100644 crates/originweave-bap/tests/task_lifecycle.rs delete mode 100644 crates/originweave-bap/tests/task_lifecycle_recovery.rs create mode 100644 crates/originweave-core/src/browser_authority_registry.rs create mode 100644 crates/originweave-core/src/browser_protocol.rs create mode 100644 crates/originweave-core/src/browser_protocol_dispatch.rs create mode 100644 crates/originweave-core/src/browser_protocol_operation.rs create mode 100644 crates/originweave-core/src/browser_registry.rs create mode 100644 crates/originweave-core/src/browser_registry_coverage.rs create mode 100644 crates/originweave-core/src/contracts.rs delete mode 100644 crates/originweave-core/src/mcp.rs delete mode 100644 crates/originweave-core/src/release_acceptance.rs delete mode 100644 crates/originweave-core/src/root.rs create mode 100644 crates/originweave-core/src/webdriver_bidi_command.rs create mode 100644 crates/originweave-core/src/webdriver_bidi_error_code.rs create mode 100644 crates/originweave-core/src/webdriver_bidi_response_document.rs create mode 100644 crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs create mode 100644 crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs create mode 100644 crates/originweave-core/src/webdriver_bidi_response_envelope.rs create mode 100644 crates/originweave-core/src/webdriver_bidi_result.rs create mode 100644 crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs create mode 100644 crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs create mode 100644 crates/originweave-core/tests/browser_authority_registry.rs create mode 100644 crates/originweave-core/tests/browser_context_origin_binding.rs create mode 100644 crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs create mode 100644 crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs create mode 100644 crates/originweave-core/tests/browser_context_origin_revalidation.rs create mode 100644 crates/originweave-core/tests/browser_context_protocol_dispatch.rs create mode 100644 crates/originweave-core/tests/browser_protocol_adapter.rs create mode 100644 crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs create mode 100644 crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs create mode 100644 crates/originweave-core/tests/browser_protocol_runtime_revision.rs create mode 100644 crates/originweave-core/tests/browser_protocol_use_validation.rs create mode 100644 crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs delete mode 100644 crates/originweave-core/tests/mcp_authority_route.rs delete mode 100644 crates/originweave-core/tests/mcp_tools_list_cache.rs delete mode 100644 crates/originweave-core/tests/origin_port_syntax.rs create mode 100644 crates/originweave-core/tests/protocol_version_parsing.rs create mode 100644 crates/originweave-core/tests/protocol_version_runtime_coverage.rs delete mode 100644 crates/originweave-core/tests/release_acceptance.rs delete mode 100644 crates/originweave-core/tests/release_acceptance_canonical_text.rs delete mode 100644 crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs delete mode 100644 crates/originweave-core/tests/release_acceptance_resource_bounds.rs delete mode 100644 crates/originweave-core/tests/release_acceptance_unicode17.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_response_envelope_hostile_json.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_response_error_code.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs create mode 100644 crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs delete mode 100644 crates/originweave-destination/tests/proxy_port_syntax.rs delete mode 100644 crates/originweave-destination/tests/resolution_freshness.rs delete mode 100644 crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs delete mode 100644 crates/originweave-evidence/src/extraction_schema.rs delete mode 100644 crates/originweave-evidence/src/sensitive_handle_lifecycle.rs create mode 100644 crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs delete mode 100644 crates/originweave-evidence/tests/extraction_normalization.rs delete mode 100644 crates/originweave-evidence/tests/extraction_schema.rs delete mode 100644 crates/originweave-evidence/tests/extraction_schema_error_contract.rs delete mode 100644 crates/originweave-evidence/tests/extraction_source_channel_set.rs delete mode 100644 crates/originweave-evidence/tests/sensitive_handle_access_binding.rs delete mode 100644 crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs create mode 100644 crates/originweave-network/src/webdriver_bidi_connection.rs create mode 100644 crates/originweave-network/src/webdriver_bidi_connection/error.rs create mode 100644 crates/originweave-network/src/webdriver_bidi_connection/tests.rs create mode 100644 crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs delete mode 100644 crates/originweave-policy/tests/extension_mutation_isolation.rs delete mode 100644 crates/originweave-policy/tests/extension_policy_isolation.rs delete mode 100644 crates/originweave-policy/tests/extension_secret_isolation.rs delete mode 100644 crates/originweave-policy/tests/mcp_route_binding.rs delete mode 100644 crates/originweave-resource/tests/error_contract.rs delete mode 100644 crates/originweave-tls/src/revocation.rs delete mode 100644 crates/originweave-tls/tests/revocation_freshness.rs delete mode 100644 docs/adr/0016-bap-task-lifecycle-authority.md delete mode 100644 docs/doctoring/rust-toolchain-freshness.md delete mode 100644 docs/product-technical-gap-baseline.md delete mode 100644 docs/traceability/mcp-authority-route.md delete mode 100644 tests/fixtures/agent_task_basic/index.html delete mode 100644 tests/test_agent_task_fixture_contract.py delete mode 100644 tests/test_doctoring_reference_contract.py delete mode 100644 tests/test_gap_snapshot_inventory_consistency.py delete mode 100644 tests/test_product_completion_gap_contract.py delete mode 100644 tests/test_rust_toolchain_contract.py create mode 100644 tests/test_webdriver_bidi_connect_target_governance.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index d331df5fd..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "rust-toolchain" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 1 diff --git a/.github/workflows/apply-rust-nightly-refresh.yml b/.github/workflows/apply-rust-nightly-refresh.yml deleted file mode 100644 index 7f3186b39..000000000 --- a/.github/workflows/apply-rust-nightly-refresh.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Materialize Rust nightly refresh once - -on: - pull_request: - branches: [main] - -permissions: - contents: read - -jobs: - materialize-owned-branch: - if: >- - github.repository == 'ContextualWisdomLab/OriginWeave' && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.head.ref == 'agent/rust-toolchain-refresh-2026-08-19' && - github.event.pull_request.user.login == 'seonghobae' - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - - name: Materialize only the reviewed nightly snapshot - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - source_path = Path('.github/workflows/hourly-product-development.yml') - source = source_path.read_text(encoding='utf-8') - old = 'nightly-2026-08-01' - new = 'nightly-2026-08-18' - old_count = source.count(old) - new_count = source.count(new) - if old_count == 2 and new_count == 0: - refreshed_source = source.replace(old, new) - elif old_count == 0 and new_count == 2: - refreshed_source = source - else: - raise SystemExit( - f'expected exactly two selectors in one state, found old={old_count}, new={new_count}' - ) - output = Path('nightly-refresh-artifact/hourly-product-development.yml') - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(refreshed_source, encoding='utf-8') - refreshed = output.read_text(encoding='utf-8') - if old in refreshed or refreshed.count(new) < 2: - raise SystemExit('nightly refresh artifact failed its replacement contract') - PY - - name: Upload exact refreshed workflow - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: hourly-rust-nightly-${{ github.event.pull_request.head.sha }} - path: nightly-refresh-artifact/hourly-product-development.yml - if-no-files-found: error - retention-days: 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95c2fa1d7..99d8d6ee8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,13 +73,13 @@ jobs: persist-credentials: false - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 with: - toolchain: nightly-2026-08-18 + toolchain: nightly-2026-08-01 components: llvm-tools-preview - name: Install pinned cargo-llvm-cov run: cargo +1.97.1 install cargo-llvm-cov --version 0.8.6 --locked - name: Measure production functions, lines, regions, and branches run: >- - cargo +nightly-2026-08-18 llvm-cov + cargo +nightly-2026-08-01 llvm-cov --locked --workspace --all-features @@ -88,11 +88,11 @@ jobs: --output-path coverage.json - name: Record uncovered production lines run: >- - cargo +nightly-2026-08-18 llvm-cov report + cargo +nightly-2026-08-01 llvm-cov report --branch --text --show-missing-lines - > missing-lines.txt + | tee missing-lines.txt - name: Upload exact coverage diagnostics uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/.github/workflows/hourly-product-development.yml b/.github/workflows/hourly-product-development.yml index 396af4a95..672754c69 100644 --- a/.github/workflows/hourly-product-development.yml +++ b/.github/workflows/hourly-product-development.yml @@ -154,7 +154,7 @@ jobs: run: | set -euo pipefail rustup toolchain install 1.97.1 --profile minimal --component clippy,rustfmt - rustup toolchain install nightly-2026-08-18 --profile minimal --component llvm-tools-preview + rustup toolchain install nightly-2026-08-01 --profile minimal --component llvm-tools-preview cargo +1.97.1 install cargo-llvm-cov --version 0.8.6 --locked archive="${RUNNER_TEMP}/opencode-linux-x64.tar.gz" curl -fsSL -o "$archive" \ @@ -894,7 +894,7 @@ jobs: cargo +1.97.1 test --locked --workspace --all-targets cargo +1.97.1 clippy --locked --workspace --all-targets -- -D warnings RUSTDOCFLAGS='-D warnings' cargo +1.97.1 doc --locked --workspace --no-deps - cargo +nightly-2026-08-18 llvm-cov \ + cargo +nightly-2026-08-01 llvm-cov \ --locked --workspace --all-features --branch --json --summary-only \ --output-path "${RUNNER_TEMP}/coverage.json" python3 scripts/ci/verify_coverage.py "${RUNNER_TEMP}/coverage.json" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fe287389b..be1bd6096 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,7 +12,6 @@ This file is the canonical product-wide topology and bounded-context view. It is - [Requirement, decision, standards, and implementation traceability](docs/traceability/README.md) - [Research and standards doctoring](docs/doctoring.md) - [Product roadmap](docs/product-roadmap.md) -- [Live product and technical gap baseline](docs/product-technical-gap-baseline.md) Protected-main code and executable tests define current implementation truth; deployed build/release artifacts, migrations, and configuration are additional operational evidence when they exist. Accepted ADRs define design authority, not proof that planned behavior has shipped. The PRD/TRD/diagrams may also contain `Planned`, `Proposed`, or `Open` product direction; those labels must remain explicit until corresponding implementation and review evidence reaches protected `main`. @@ -167,7 +166,7 @@ Observation should prefer the most structured trustworthy source available: 4. accessibility tree combined with DOM and layout; 5. screenshot or vision fallback for canvas and inaccessible custom interfaces. -Raw HTML is not the default model input. Full snapshots are followed by incremental semantic diffs, versioned by document epoch. Node references become invalid after navigation or epoch change. +Raw HTML is not the default model input. Full snapshots are followed by incremental semantic diffs, versioned by document epoch. Node references become invalid after navigation or epoch change. An untrusted WebDriver BiDi `locateNodes` result becomes an `ObservedNodeHandle` only after the adapter transfers a non-cloneable `QueryNodes` / `SemanticObservation` protocol-use proof into `bind_current_nodes` and the exact current session, browsing context, canonical origin, and document epoch still match. Navigation or TypedInput proofs fail closed. That control-plane composition does not perform browser I/O or authorize typed input. ## 8. Action lifecycle diff --git a/CHANGELOG.md b/CHANGELOG.md index f747adeae..8a3ba2526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,51 +4,55 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] -- Refreshed the product-gap queue to 126 open pull requests (54 ready, 72 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review; these are queue-consolidation results, not protected-main shipment. - ### Added -- 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. -- 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. -- Added a dated product and technical gap baseline that separates protected-main implementation truth, active pull-request evidence, live review/check blockers, and the next buyer-visible Phase 1 acceptance work. -- Refreshed the product and technical gap baseline with the current open-PR inventory and exact base/head evidence for the newest Chromium, BAP, extraction, WARC, and idempotency slices. -- Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. -- Bound explicit extension-to-Agent grants to the exact canonical origin in addition to extension identity, session, and browsing context, so a same-session navigation or port change cannot reuse the grant. +- Bounded WebDriver BiDi loopback TCP transport that consumes one exact no-DNS connect target, retries only explicitly recoverable local transport failures within repository timeout and attempt ceilings, exposes the stream only after operating-system peer inspection and exact peer verification, supports a consuming handoff of the original stream with typed credential-free peer/session/TLS and bounded-attempt evidence, preserves typed causal errors, and performs no DNS, proxy/PAC, process authentication, TLS, WebSocket, BiDi message, browser-action, or Agent-authority step. +- Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. +- Explicit no-DNS WebDriver BiDi loopback connection targets that derive exact IPv4/IPv6 loopback `SocketAddr` metadata from a session-correlated endpoint, reject `localhost` as requiring separately trusted name resolution, preserve the TLS requirement and exact session id, perform no socket I/O, and grant no Agent authority. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. +- Versioned browser-protocol adapter metadata that distinguishes WebDriver BiDi from pinned CDP, binds bounded adapter/browser revision tokens to an explicit duplicate-free capability set, normalizes capability-set identity independently of caller ordering, and exposes typed fail-closed capability requirements without granting browser, action, network, or secret authority by protocol kind alone. +- Canonical OriginWeave protocol-version parsing for exact `originweave/.` syntax, with typed fail-closed rejection of malformed, ambiguous, overflowed, or noncanonical serialized generations; parsing does not negotiate compatibility or grant adapter authority. +- Atomic browser-protocol use validation that requires the exact OriginWeave protocol generation, caller-supplied runtime protocol family, exact pinned runtime protocol/browser revisions, and an explicitly declared capability in deterministic fail-closed order before producing non-cloneable validation evidence; this metadata proof does not authenticate the adapter or grant browser/Agent authority. +- Runtime browser-adapter version binding at the atomic protocol-use boundary: the caller-supplied bounded adapter-version token must exactly match the reviewed descriptor version before runtime revision or capability checks can succeed, preventing adapter-build drift from silently reusing otherwise matching protocol/browser metadata without authenticating or attesting the adapter process. +- Same-call browser-protocol dispatch gating that validates current protocol family, adapter version, pinned protocol/browser revisions, OriginWeave generation, and required capability before invoking one callback, transferring the non-cloneable validation proof by ownership without turning metadata validation into browser or Agent authority. +- Context-bound browser-protocol dispatch composition that revalidates the exact OriginWeave browser session/context pair, carries the registry's current document epoch into the immediate callback, and separately requires the same exact runtime protocol metadata/capability checks before dispatch without claiming origin, destination, typed-input, transport-authentication, or post-condition authority. +- Typed browser-protocol operations that derive the exact adapter capability inside the strongest current context/origin/document-epoch dispatch boundary, preventing callers from independently selecting mismatched operation and capability metadata without performing browser I/O or granting policy authority. +- Bounded WebDriver BiDi accessibility-node query parameters for `browsingContext.locateNodes`, with reviewed selector/result budgets, exact-token role and control-free accessible-name admission, minimal remote-value serialization options, and fail-closed revalidation that rejects an untrusted adapter response whose returned node count exceeds the exact request budget before node normalization or retention. +- Deterministic WebDriver BiDi `browsingContext.locateNodes` command serialization that accepts only protocol-range `js-uint` identifiers and bounded injection-safe browsing-context identifiers, JSON-escapes reviewed query text, and emits the exact accessibility locator, finite node budget, and minimal serialization options without performing transport I/O or granting browser or Agent authority. +- Fail-closed WebDriver BiDi `locateNodes` response correlation that consumes the exact serialized command, rejects out-of-range or mismatched response `id` values, and returns non-cloneable correlation evidence carrying only the matched command identifier and browsing context without parsing the response or granting browser/Agent authority. +- Typed WebDriver BiDi response-envelope correlation that preserves success/error classification, rejects success responses with absent ids, treats nullable error ids as explicitly uncorrelatable, and prevents correlated error envelopes from becoming success correlation evidence without raw JSON parsing or browser/Agent authority grants. +- Bounded raw WebDriver BiDi response-document admission before JSON parsing, with a 65,536-byte product safety budget, exact wire-text retention, JSON-whitespace-aware top-level object-boundary checks, and typed fail-closed errors; this coarse boundary deliberately does not claim JSON validity, response correlation, browser authenticity, or Agent authority. +- One consuming bounded WebDriver BiDi response-document correlation boundary that parses the exact admitted JSON document and feeds only its typed response kind and protocol-range id into the existing exact command-correlation gate, preserving nested parser/correlation error sources without authenticating transport or granting browser/Agent authority. +- Consuming WebDriver BiDi WebSocket endpoint/session correlation that validates one caller-supplied canonical session UUID and rejects exact session mismatches before later transport use; the correlated type preserves only bounded endpoint metadata and does not authenticate Chromium, ChromeDriver, the caller, or the socket peer. +- Fail-closed WebDriver BiDi `script.NodeRemoteValue` admission that requires the exact remote type `node` and a non-empty `sharedId` within the same UTF-8 identifier budget as browser session and context identifiers, rejecting control and whitespace so an untrusted `locateNodes` item cannot be retained as a later typed-input handle without a usable shared node identity. +- Same-call `locateNodes` result admission that revalidates the exact current session, browsing context, canonical origin, and document epoch, rejects an over-budget or non-node result, and translates each admitted `sharedId` through the authority registry into an `ObservedNodeHandle` without performing browser I/O. +- Same-call QueryNodes admission that transfers a non-cloneable SemanticObservation protocol-use proof by ownership into `bind_current_nodes` before an untrusted `locateNodes` result can become current `ObservedNodeHandle` values, so Navigation-only or TypedInput-only proofs cannot mint observation handles. +- Fail-closed rejection of reviewed Unicode format and bidirectional-override characters in accessibility roles, accessible names, BiDi `sharedId` values, and registry external identifiers, while ordinary spaces in accessible names remain valid. +- Credential-safe browser-protocol validation evidence that copies only the already validated protocol family, OriginWeave generation, adapter version, pinned protocol/browser revisions, and exact capability into cloneable audit metadata without recreating the non-cloneable validation prerequisite or granting browser/Agent authority. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. -- Protected main now contains deterministic MCP `2026-07-28` stateless `tools/call` routing with bounded method/tool names, a single reviewed tool-to-action registry shared by routing and discovery metadata, and fail-closed policy binding that grants no ambient authority. The complete MCP adapter, transport serialization, discovery response handling, OAuth, browser I/O, and persistence remain planned. -- Active PR #170 adds conservative MCP `2026-07-28` `tools/list` discovery metadata derived from that protected-main catalog, with `resultType = complete`, zero freshness, private cache scope, no continuation cursor, per-request protocol/client-capability admission, and bounded protocol-version and method metadata validated before cross-field comparison. This remains active-PR evidence only and grants no browser, network, secret, approval, or Agent authority. - Deterministic fail-closed policy evaluation for untrusted instructions, origin grants, crawler restrictions, execution-mode and purpose consistency, approvals, and brokered secrets. - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. -- Bounded resolution-freshness authority with trusted monotonic approval time, capped non-zero validity, half-open use windows, non-expanding revalidation, and credential-free authorization timestamps. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. - Authenticated `originweave-tls` service-identity boundary that consumes an existing verified TCP stream, requires exact TLS-origin and transport-origin equality, derives RFC 9525 DNS or literal-IP reference identity only from the canonical HTTPS origin, validates WebPKI with explicit roots and fixed time, permits only TLS 1.2 and TLS 1.3, and never reconnects or resolves. - Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior and `NotConfigured` revocation evidence. -- Deterministic TLS revocation-material freshness authority with a strict signed `thisUpdate`→`nextUpdate` half-open window and typed invalid-window, not-yet-valid, and stale failures, without claiming OCSP/CRL acquisition, cryptographic validation, or certificate revocation status. -- Credential-free TLS evidence containing canonical origin, TCP peers, reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration. -- Credential-free sensitive-handle lifecycle evidence binds issuance, exclusive expiry, bounded uses, observed resolution count, and revocation to the exact credential-free `OpaqueHandleOnly` sensitive-access receipt, preserving tenant, actor, task, field set, purpose, destination, classification, policy version, and decision time without storing opaque handle tokens or protected values. +- Credential-free TLS evidence containing canonical origin, requested and observed peer, DNS/IP reference identity, TLS version, cipher-suite identifier, selected ALPN or explicit absence, leaf certificate and SPKI hashes, server-presented certificate hashes and bounds, trust-bundle identity and hash, validity interval, fixed verification time, revocation configuration, and measured handshake duration. - Credential-free connection and redirect evidence containing canonical addresses, destination classes, target digests, hop numbers, and approved-address counts. - Credential-free verified TCP evidence containing the logical origin, requested socket, observed peer, destination class, successful attempt number, and per-attempt timeout. -- Standard `Display` and `std::error::Error` contracts for destination, redirect, digest, direct-network, TLS, and resource-budget failures, including preserved destination-policy, rustls, and operating-system sources where applicable. +- Standard `Display` and `std::error::Error` contracts for destination, redirect, digest, direct-network, and TLS failures, including preserved destination-policy, rustls, and operating-system sources where applicable. - Real loopback TCP integration proof plus deterministic timeout, refusal, retry, peer-inspection, peer-mismatch, canonicalization, IPv6 metadata, and single-use replay tests. - Real loopback rustls integration covering trusted DNS SAN, Common-Name fallback rejection, wrong-name and untrusted-root rejection, fixed-time expiry and not-yet-valid failures, exact IPv4 and IPv6 SANs, TLS 1.2/TLS 1.3, required and optional ALPN, and transport-origin binding. - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. -- Versioned schema-bound extraction contracts with bounded identifiers and field counts, typed value/cardinality metadata, explicit duplicate-free reviewed source channels, fail-closed schema validation, and deterministic `Display`/`std::error::Error` contracts for public schema failures. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. -- Resumable BAP lifecycle restoration with monotonic sequence recovery and fail-closed sequence exhaustion. - Authoritative product documentation graph spanning PRD, TRD, ADR lifecycle/index, product-wide UML, conceptual ERD, requirement/decision traceability, threat modeling, product-wide test strategy, operability, API/protocol, release/rollback, and current primary-source standards doctoring, with machine-checkable repository contracts that keep conversation-derived future work distinct from protected-main implementation claims. - Purpose-bound data-governance and privacy baseline that rejects both blanket masking and ambient raw-value propagation, defines field-scoped just-in-time disclosure, opaque-handle/trusted-broker boundaries, model/provider/region policy, retention/deletion/residency/break-glass controls, truthful CSAP/SOC 2 readiness language, and machine-checkable documentation contracts without inventing an OriginWeave-owned production database. - Proposed product-wide target-architecture ADRs for the Rust control plane, isolated execution modes, typed actions, semantic observation/stale-node authority, prompt-injection and secret separation, resource-governor priority, provenance evidence, browser/protocol adapters, crawler policy, and hourly automation operational closure; these remain Proposed rather than shipped claims until protected review and merge. ### Changed -- 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. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. @@ -57,20 +61,12 @@ All notable changes to OriginWeave are documented in this file. The format follo - Updated the first Chromium slice to distinguish implemented origin, destination, direct TCP, and TLS identity kernels from the remaining trusted DNS adapter, proxy/PAC, HTTP budget, MIME, download, and Chromium integration required before safe navigation can be claimed. - Separated hourly product PR publication authority from the organization review and merge system, and added live default-branch and release-blocker rechecks immediately before publication. - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. -- Hardened the dated baseline evidence collector with fail-fast isolated artifacts, paginated branch and collaborator rules, and post-collection exact-head revalidation. -- Flattened every paginated workflow-run page in the baseline merge verdict so exact-head evidence cannot silently discard later runs. -- Hardened the baseline evidence procedure with exact-head legacy status and workflow-run capture, counted approval binding, required-workflow recording, merge verdict artifacts, and bounded moving-head retries. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. +- Kept the real loopback WebDriver BiDi opening-write regression test fail-fast with test-only diagnostics, while explicitly covering successful and panicked server-thread handoffs so strict all-target Clippy and exact coverage remain clean. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. -- Tightened the product-baseline contract so the BiDi opening path and VPN/profile evidence retain their explicit not-shipped status within their own documentation sections. -- Refreshed the product and technical gap baseline against the 2026-08-21 live inventory: 150 open pull requests, 110 drafts, and the new hardened-runner/MV3 evidence gap issue #206. -- Tightened the baseline completion-gap contract so superseded inventory counts (including the 2026-08-21 150/40/110 snapshot) can no longer pass as current evidence. -- Refreshed the baseline's merge-authority statement to the live ruleset: two approving reviews are required, while the collaborator inventory still contains only the solo maintainer. -- Corrected the baseline evidence collector to flatten every paginated input, apply current reviewer and last-push approval semantics, and discard verdicts when either the PR head or base moves. ### Security -- Explicit proxy server identifiers require ASCII decimal port tokens before numeric range parsing, preventing Rust-specific leading-plus spellings from widening proxy authority. - Raw page content cannot become a trusted instruction. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. - Crawler mode is read-only, must pair with the public-crawl purpose, and fails closed without an applicable robots-policy decision. @@ -90,16 +86,14 @@ All notable changes to OriginWeave are documented in this file. The format follo - TLS accepts only an already verified direct stream, never a hostname or new socket, and requires the TLS origin to match the transport-authority origin exactly. - 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. - 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. - Every generic network header and query value is redacted before evidence leaves the trusted boundary, including conventionally benign field names containing attacker-controlled bytes. - Evidence capture enforces count and byte bounds and rejects credential-bearing source URLs, query strings, fragments, controls, whitespace, malformed percent escapes, encoded separators, dot segments, and backslash paths. -- Network-evidence paths and provenance source URL paths accept only RFC 3986 literal `pchar` syntax plus validated percent-encoded octets and slash separators, preventing raw general delimiters such as `[` and `]` or other invalid URI-presentation bytes from entering either evidence surface. - Hard RAM and VRAM pressure pauses the active agent and rejects new admission; hard VRAM pressure also offloads a resident local model. - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/Cargo.lock b/Cargo.lock index 848cb7320..e2ada3c4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,16 +263,9 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "originweave-bap" -version = "0.1.0" - [[package]] name = "originweave-core" version = "0.1.0" -dependencies = [ - "unicode-normalization", -] [[package]] name = "originweave-destination" @@ -561,21 +554,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "typenum" version = "1.20.1" @@ -588,15 +566,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 0d5ab469c..fc723f3a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] members = [ "crates/originweave-core", - "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-resource", "crates/originweave-evidence", diff --git a/README.md b/README.md index 0942976cf..17085c05d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ OriginWeave is a Chromium-compatible, Rust-first control plane for governed AI agents on the web. It is designed to let an agent observe, extract, and act without turning untrusted page content into authority, exposing secrets to a model, connecting to an unapproved network destination, accepting an unauthenticated web service, or losing the evidence required to explain what happened. -> Project status: pre-alpha. The current protected repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, authenticated TLS service-identity, and bounded MCP `2026-07-28` stateless `tools/call` routing/policy foundations. Chromium, WebDriver BiDi, CDP, complete MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. Active PR #170 implements only conservative `tools/list` discovery metadata on top of the protected-main MCP catalog; it remains non-shipped active-PR evidence and does not make the complete MCP adapter available. +> Project status: pre-alpha. The current repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, and authenticated TLS service-identity kernels. Chromium, WebDriver BiDi, CDP, MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. ## Why OriginWeave @@ -40,8 +40,6 @@ The repository is organized as independently consumable Rust crates: - `originweave-resource`: task-level RAM, VRAM, thread, and frame-time budgets with cumulative mitigation plans. - `originweave-evidence`: universally value-redacted network evidence and source-bound provenance records. -Protected main additionally contains an `originweave-core` MCP routing registry and `originweave-policy` binding for the MCP `2026-07-28` `tools/call` boundary. That shipped foundation validates and maps an explicit tool name to an existing typed action while preserving normal OriginWeave policy. Active PR #170 adds non-shipped conservative `tools/list` discovery metadata derived from the same reviewed catalog. Neither boundary implements transport parsing, OAuth, browser control, secret materialization, persistence, or ambient authority. - See [ARCHITECTURE.md](ARCHITECTURE.md) and the [architecture decision records](docs/adr/) for binding design decisions. ## Safety model @@ -99,7 +97,7 @@ isolated Chromium session → redacted provenance bundle ``` -Subsequent work connects the live Chromium network service, adds explicit proxy and download policy, WARC/PROV persistence, completes the MCP and Browser Agent Protocol adapters beyond the protected-main `tools/call` foundation and active `tools/list` refinement, expands extension compatibility testing, adds GPU/RAM telemetry and prompt-injection benchmarks, and builds an accessible approval interface. See [docs/product-roadmap.md](docs/product-roadmap.md). +Subsequent work connects the live Chromium network service, adds explicit proxy and download policy, WARC/PROV persistence, MCP and Browser Agent Protocol adapters, extension compatibility testing, GPU/RAM telemetry, prompt-injection benchmarks, and an accessible approval interface. See [docs/product-roadmap.md](docs/product-roadmap.md). ## Hourly product-development loop @@ -111,4 +109,4 @@ Read [AGENTS.md](AGENTS.md), [CONTRIBUTING.md](CONTRIBUTING.md), and [SECURITY.m ## License -Apache License 2.0. See [LICENSE](LICENSE). \ No newline at end of file +Apache License 2.0. See [LICENSE](LICENSE). diff --git a/crates/originweave-bap/Cargo.toml b/crates/originweave-bap/Cargo.toml deleted file mode 100644 index 39e8e38f7..000000000 --- a/crates/originweave-bap/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "originweave-bap" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -authors.workspace = true -repository.workspace = true -homepage.workspace = true - -[lints] -workspace = true diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs deleted file mode 100644 index 404a88c10..000000000 --- a/crates/originweave-bap/src/lib.rs +++ /dev/null @@ -1,329 +0,0 @@ -//! Stable internal Browser Agent Protocol lifecycle contracts. -//! -//! This crate intentionally owns no transport, browser, network, model, secret, -//! approval, or persistence authority. External protocol adapters may project -//! these states, but protocol metadata cannot mint or change OriginWeave task -//! authority. - -#![forbid(unsafe_code)] -#![deny(missing_docs)] - -/// Durable logical state of one governed BAP task. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BapTaskState { - /// The task record exists but has not entered admission control. - Created, - /// Admission control accepted the task but execution has not started. - Admitted, - /// The task is actively executing governed work. - Running, - /// Execution is suspended until an approval decision is available. - WaitingForApproval, - /// Execution is suspended until required external input is available. - WaitingForExternalInput, - /// Execution is suspended at a compatible recoverable checkpoint. - Checkpointed, - /// Execution is suspended until an explicit reconciliation decision is recorded. - /// - /// The lifecycle state does not itself persist or authenticate reconciliation - /// evidence. A durable owner must preserve the complete evidence that caused - /// the task to enter this state before resolution is considered. - ReconciliationRequired, - /// The declared post-condition completed successfully. - Succeeded, - /// The task reached a terminal execution failure. - Failed, - /// Cancellation completed and the task cannot resume. - Cancelled, - /// The task exceeded its allowed lifetime and cannot resume. - Expired, - /// The task was terminally removed from automatic execution after governed handling. - /// - /// Durable dead-letter evidence remains the responsibility of the persistence - /// boundary; this in-memory marker must not be treated as the evidence itself. - DeadLettered, -} - -impl BapTaskState { - /// Return whether this state is final and must never transition again. - #[must_use] - pub const fn is_terminal(self) -> bool { - matches!( - self, - Self::Succeeded | Self::Failed | Self::Cancelled | Self::Expired | Self::DeadLettered - ) - } -} - -/// One requested task-lifecycle event. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BapTaskEvent { - /// Admit a newly created task. - Admit, - /// Start an admitted task. - Start, - /// Suspend a running task until approval is available. - WaitForApproval, - /// Suspend a running task until external input is available. - WaitForExternalInput, - /// Suspend a running task at a recoverable checkpoint. - Checkpoint, - /// Resume a normal suspended task into governed execution. - Resume, - /// Suspend a running task because its external outcome requires reconciliation. - RequireReconciliation, - /// Explicitly resolve a reconciliation hold and return the task to governed execution. - ResolveReconciliation, - /// Terminally remove a running or reconciliation-held task from automatic execution. - DeadLetter, - /// Record successful completion after the declared post-condition is verified. - Succeed, - /// Record terminal task failure. - Fail, - /// Record terminal cancellation. - Cancel, - /// Record terminal expiry. - Expire, -} - -/// A fail-closed lifecycle transition failure. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BapTaskTransitionError { - /// The requested event is not valid from the current non-terminal state. - InvalidTransition { - /// Current state that rejected the event. - from: BapTaskState, - /// Event that was rejected. - event: BapTaskEvent, - }, - /// The lifecycle sequence reached its maximum representable value. - SequenceExhausted, - /// A terminal task cannot be reopened or mutated by lifecycle events. - TerminalState { - /// Final state that rejected all further events. - state: BapTaskState, - }, -} - -impl std::fmt::Display for BapTaskTransitionError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidTransition { from, event } => { - write!( - formatter, - "BAP task event {event:?} is invalid from state {from:?}" - ) - } - Self::SequenceExhausted => { - write!(formatter, "BAP task transition sequence is exhausted") - } - Self::TerminalState { state } => { - write!(formatter, "BAP task state {state:?} is terminal") - } - } - } -} - -impl std::error::Error for BapTaskTransitionError {} - -/// A fail-closed lifecycle recovery failure. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BapTaskRestoreError { - /// The supplied state and transition sequence cannot arise from this state machine. - InvalidSnapshot { - /// Logical state supplied by the durable recovery boundary. - state: BapTaskState, - /// Last accepted transition sequence supplied by the durable recovery boundary. - transition_sequence: u64, - }, -} - -impl std::fmt::Display for BapTaskRestoreError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidSnapshot { - state, - transition_sequence, - } => write!( - formatter, - "BAP task snapshot state {state:?} with transition sequence {transition_sequence} is unreachable" - ), - } - } -} - -impl std::error::Error for BapTaskRestoreError {} - -/// Immutable receipt for one accepted in-memory lifecycle transition. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BapTaskTransition { - previous_state: BapTaskState, - current_state: BapTaskState, - sequence: u64, -} - -impl BapTaskTransition { - /// Return the state before the accepted transition. - #[must_use] - pub const fn previous_state(self) -> BapTaskState { - self.previous_state - } - - /// Return the state after the accepted transition. - #[must_use] - pub const fn current_state(self) -> BapTaskState { - self.current_state - } - - /// Return the monotonic transition sequence for this lifecycle instance. - #[must_use] - pub const fn sequence(self) -> u64 { - self.sequence - } -} - -/// Deterministic fail-closed BAP task-lifecycle kernel. -/// -/// This value is intentionally an in-memory state-transition primitive. A -/// durable repository must persist accepted transitions and impose its own -/// bounded sequence/retention contract before commercial task recovery can be -/// claimed. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BapTaskLifecycle { - state: BapTaskState, - transition_sequence: u64, -} - -impl Default for BapTaskLifecycle { - fn default() -> Self { - Self::new() - } -} - -impl BapTaskLifecycle { - /// Create one lifecycle in the `created` state with no accepted transitions. - #[must_use] - pub const fn new() -> Self { - Self { - state: BapTaskState::Created, - transition_sequence: 0, - } - } - - /// Restore a lifecycle state and its last accepted transition sequence. - /// - /// Recovery accepts only state/sequence pairs that are reachable through - /// this exact state machine. This prevents corrupt or stale durable metadata - /// from manufacturing an impossible execution state. - pub const fn restore( - state: BapTaskState, - transition_sequence: u64, - ) -> Result { - if !reachable_snapshot(state, transition_sequence) { - return Err(BapTaskRestoreError::InvalidSnapshot { - state, - transition_sequence, - }); - } - Ok(Self { - state, - transition_sequence, - }) - } - - /// Return the current logical task state. - #[must_use] - pub const fn state(self) -> BapTaskState { - self.state - } - - /// Return the number of accepted lifecycle transitions. - #[must_use] - pub const fn transition_sequence(self) -> u64 { - self.transition_sequence - } - - /// Apply one reviewed lifecycle event without granting execution authority. - /// - /// Rejected events leave both state and sequence unchanged. Terminal states - /// reject every later event before evaluating any normal transition rule. - /// Reconciliation cannot use the generic `Resume` event: it requires the - /// explicit `ResolveReconciliation` event so ambiguous external outcomes - /// cannot silently re-enter execution. - pub fn apply( - &mut self, - event: BapTaskEvent, - ) -> Result { - if self.state.is_terminal() { - return Err(BapTaskTransitionError::TerminalState { state: self.state }); - } - - let next_state = match (self.state, event) { - (BapTaskState::Created, BapTaskEvent::Admit) => BapTaskState::Admitted, - (BapTaskState::Admitted, BapTaskEvent::Start) => BapTaskState::Running, - (BapTaskState::Running, BapTaskEvent::WaitForApproval) => { - BapTaskState::WaitingForApproval - } - (BapTaskState::Running, BapTaskEvent::WaitForExternalInput) => { - BapTaskState::WaitingForExternalInput - } - (BapTaskState::Running, BapTaskEvent::Checkpoint) => BapTaskState::Checkpointed, - ( - BapTaskState::WaitingForApproval - | BapTaskState::WaitingForExternalInput - | BapTaskState::Checkpointed, - BapTaskEvent::Resume, - ) => BapTaskState::Running, - (BapTaskState::Running, BapTaskEvent::RequireReconciliation) => { - BapTaskState::ReconciliationRequired - } - (BapTaskState::ReconciliationRequired, BapTaskEvent::ResolveReconciliation) => { - BapTaskState::Running - } - ( - BapTaskState::Running | BapTaskState::ReconciliationRequired, - BapTaskEvent::DeadLetter, - ) => BapTaskState::DeadLettered, - (BapTaskState::Running, BapTaskEvent::Succeed) => BapTaskState::Succeeded, - (_, BapTaskEvent::Fail) => BapTaskState::Failed, - (_, BapTaskEvent::Cancel) => BapTaskState::Cancelled, - (_, BapTaskEvent::Expire) => BapTaskState::Expired, - (from, event) => { - return Err(BapTaskTransitionError::InvalidTransition { from, event }); - } - }; - - let Some(sequence) = self.transition_sequence.checked_add(1) else { - return Err(BapTaskTransitionError::SequenceExhausted); - }; - let previous_state = self.state; - self.state = next_state; - self.transition_sequence = sequence; - Ok(BapTaskTransition { - previous_state, - current_state: next_state, - sequence, - }) - } -} - -const fn reachable_snapshot(state: BapTaskState, transition_sequence: u64) -> bool { - match state { - BapTaskState::Created => transition_sequence == 0, - BapTaskState::Admitted => transition_sequence == 1, - BapTaskState::Running => transition_sequence >= 2 && transition_sequence.is_multiple_of(2), - BapTaskState::WaitingForApproval - | BapTaskState::WaitingForExternalInput - | BapTaskState::Checkpointed - | BapTaskState::ReconciliationRequired => { - transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) - } - BapTaskState::Succeeded => { - transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) - } - BapTaskState::Failed | BapTaskState::Cancelled | BapTaskState::Expired => { - transition_sequence >= 1 - } - BapTaskState::DeadLettered => transition_sequence >= 3, - } -} diff --git a/crates/originweave-bap/tests/task_lifecycle.rs b/crates/originweave-bap/tests/task_lifecycle.rs deleted file mode 100644 index 01013682a..000000000 --- a/crates/originweave-bap/tests/task_lifecycle.rs +++ /dev/null @@ -1,253 +0,0 @@ -#![allow(clippy::expect_used)] - -use originweave_bap::{BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransitionError}; - -#[test] -fn default_starts_a_new_created_lifecycle() { - assert_eq!(BapTaskLifecycle::default(), BapTaskLifecycle::new()); -} - -#[test] -fn bap_task_lifecycle_follows_the_reviewed_resumable_path() { - let mut task = BapTaskLifecycle::new(); - assert_eq!(task.state(), BapTaskState::Created); - assert!(!task.state().is_terminal()); - assert_eq!(task.transition_sequence(), 0); - - let admitted = task.apply(BapTaskEvent::Admit).expect("admit"); - assert_eq!(admitted.previous_state(), BapTaskState::Created); - assert_eq!(admitted.current_state(), BapTaskState::Admitted); - assert_eq!(admitted.sequence(), 1); - - task.apply(BapTaskEvent::Start).expect("start"); - task.apply(BapTaskEvent::WaitForApproval) - .expect("wait for approval"); - assert_eq!(task.state(), BapTaskState::WaitingForApproval); - - task.apply(BapTaskEvent::Resume).expect("resume approval"); - task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); - assert_eq!(task.state(), BapTaskState::Checkpointed); - - task.apply(BapTaskEvent::Resume).expect("resume checkpoint"); - let succeeded = task.apply(BapTaskEvent::Succeed).expect("succeed"); - assert_eq!(succeeded.current_state(), BapTaskState::Succeeded); - assert!(task.state().is_terminal()); - assert_eq!(task.transition_sequence(), 7); -} - -#[test] -fn waiting_for_external_input_can_resume_but_cannot_succeed_directly() { - let mut task = running_task(); - task.apply(BapTaskEvent::WaitForExternalInput) - .expect("wait for input"); - - let error = task - .apply(BapTaskEvent::Succeed) - .expect_err("waiting task must not skip resume and post-condition work"); - assert_eq!( - error, - BapTaskTransitionError::InvalidTransition { - from: BapTaskState::WaitingForExternalInput, - event: BapTaskEvent::Succeed, - } - ); - assert_eq!(task.state(), BapTaskState::WaitingForExternalInput); - assert_eq!(task.transition_sequence(), 3); - - task.apply(BapTaskEvent::Resume).expect("resume input"); - assert_eq!(task.state(), BapTaskState::Running); -} - -#[test] -fn invalid_transition_is_fail_closed_and_does_not_advance_history() { - let mut task = BapTaskLifecycle::new(); - - let error = task - .apply(BapTaskEvent::Start) - .expect_err("created task must be admitted first"); - assert_eq!( - error, - BapTaskTransitionError::InvalidTransition { - from: BapTaskState::Created, - event: BapTaskEvent::Start, - } - ); - assert_eq!(task.state(), BapTaskState::Created); - assert_eq!(task.transition_sequence(), 0); -} - -#[test] -fn terminal_task_never_reopens_or_advances_history() { - for terminal_event in [ - BapTaskEvent::Succeed, - BapTaskEvent::Fail, - BapTaskEvent::Cancel, - BapTaskEvent::Expire, - ] { - let mut task = if terminal_event == BapTaskEvent::Succeed { - running_task() - } else { - BapTaskLifecycle::new() - }; - task.apply(terminal_event).expect("enter terminal state"); - let terminal_state = task.state(); - let terminal_sequence = task.transition_sequence(); - - for later_event in [ - BapTaskEvent::Admit, - BapTaskEvent::Start, - BapTaskEvent::Resume, - BapTaskEvent::Cancel, - ] { - assert_eq!( - task.apply(later_event), - Err(BapTaskTransitionError::TerminalState { - state: terminal_state, - }) - ); - assert_eq!(task.state(), terminal_state); - assert_eq!(task.transition_sequence(), terminal_sequence); - } - } -} - -#[test] -fn cancellation_and_expiry_cover_pre_dispatch_and_suspended_states() { - for state in [ - BapTaskState::Created, - BapTaskState::Admitted, - BapTaskState::Running, - BapTaskState::WaitingForApproval, - BapTaskState::WaitingForExternalInput, - BapTaskState::Checkpointed, - BapTaskState::ReconciliationRequired, - ] { - for terminal_event in [BapTaskEvent::Cancel, BapTaskEvent::Expire] { - let mut task = task_in_state(state); - assert_eq!(task.state(), state); - task.apply(terminal_event).expect("terminal interruption"); - assert!(task.state().is_terminal()); - } - } -} - -#[test] -fn reconciliation_requires_explicit_resolution_and_dead_letter_is_terminal() { - let mut task = running_task(); - let required = task - .apply(BapTaskEvent::RequireReconciliation) - .expect("require reconciliation"); - assert_eq!(required.previous_state(), BapTaskState::Running); - assert_eq!( - required.current_state(), - BapTaskState::ReconciliationRequired - ); - assert!(!task.state().is_terminal()); - - assert_eq!( - task.apply(BapTaskEvent::Resume), - Err(BapTaskTransitionError::InvalidTransition { - from: BapTaskState::ReconciliationRequired, - event: BapTaskEvent::Resume, - }) - ); - assert_eq!( - task.apply(BapTaskEvent::Succeed), - Err(BapTaskTransitionError::InvalidTransition { - from: BapTaskState::ReconciliationRequired, - event: BapTaskEvent::Succeed, - }) - ); - assert_eq!(task.transition_sequence(), 3); - - task.apply(BapTaskEvent::ResolveReconciliation) - .expect("resolve reconciliation"); - assert_eq!(task.state(), BapTaskState::Running); - - task.apply(BapTaskEvent::RequireReconciliation) - .expect("require reconciliation again"); - let dead_lettered = task - .apply(BapTaskEvent::DeadLetter) - .expect("dead-letter unresolved task"); - assert_eq!(dead_lettered.current_state(), BapTaskState::DeadLettered); - assert!(task.state().is_terminal()); - - assert_eq!( - task.apply(BapTaskEvent::Resume), - Err(BapTaskTransitionError::TerminalState { - state: BapTaskState::DeadLettered, - }) - ); -} - -#[test] -fn running_task_may_dead_letter_but_pre_dispatch_task_may_not() { - let mut running = running_task(); - let transition = running - .apply(BapTaskEvent::DeadLetter) - .expect("dead-letter running task"); - assert_eq!(transition.previous_state(), BapTaskState::Running); - assert_eq!(transition.current_state(), BapTaskState::DeadLettered); - assert_eq!(transition.sequence(), 3); - assert!(running.state().is_terminal()); - - let mut created = BapTaskLifecycle::new(); - assert_eq!( - created.apply(BapTaskEvent::DeadLetter), - Err(BapTaskTransitionError::InvalidTransition { - from: BapTaskState::Created, - event: BapTaskEvent::DeadLetter, - }) - ); - assert_eq!(created.state(), BapTaskState::Created); - assert_eq!(created.transition_sequence(), 0); -} - -fn running_task() -> BapTaskLifecycle { - let mut task = BapTaskLifecycle::new(); - task.apply(BapTaskEvent::Admit).expect("admit"); - task.apply(BapTaskEvent::Start).expect("start"); - task -} - -fn task_in_state(target: BapTaskState) -> BapTaskLifecycle { - let mut task = BapTaskLifecycle::new(); - if target == BapTaskState::Created { - return task; - } - - task.apply(BapTaskEvent::Admit).expect("admit"); - if target == BapTaskState::Admitted { - return task; - } - - task.apply(BapTaskEvent::Start).expect("start"); - match target { - BapTaskState::Running => {} - BapTaskState::WaitingForApproval => { - task.apply(BapTaskEvent::WaitForApproval) - .expect("wait approval"); - } - BapTaskState::WaitingForExternalInput => { - task.apply(BapTaskEvent::WaitForExternalInput) - .expect("wait external"); - } - BapTaskState::Checkpointed => { - task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); - } - BapTaskState::ReconciliationRequired => { - task.apply(BapTaskEvent::RequireReconciliation) - .expect("require reconciliation"); - } - BapTaskState::Created - | BapTaskState::Admitted - | BapTaskState::Succeeded - | BapTaskState::Failed - | BapTaskState::Cancelled - | BapTaskState::Expired - | BapTaskState::DeadLettered => { - unreachable!("task_in_state only constructs non-terminal lifecycle states") - } - } - task -} diff --git a/crates/originweave-bap/tests/task_lifecycle_recovery.rs b/crates/originweave-bap/tests/task_lifecycle_recovery.rs deleted file mode 100644 index 67deae949..000000000 --- a/crates/originweave-bap/tests/task_lifecycle_recovery.rs +++ /dev/null @@ -1,142 +0,0 @@ -#![allow(clippy::expect_used)] - -use std::error::Error as _; - -use originweave_bap::{ - BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, BapTaskState, BapTaskTransitionError, -}; - -#[test] -fn restored_lifecycle_preserves_state_and_monotonic_sequence() { - let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, 41) - .expect("valid checkpoint snapshot"); - - assert_eq!(task.state(), BapTaskState::Checkpointed); - assert_eq!(task.transition_sequence(), 41); - - let resumed = task - .apply(BapTaskEvent::Resume) - .expect("resume restored task"); - assert_eq!(resumed.previous_state(), BapTaskState::Checkpointed); - assert_eq!(resumed.current_state(), BapTaskState::Running); - assert_eq!(resumed.sequence(), 42); -} - -#[test] -fn impossible_restored_snapshots_fail_closed() { - for (state, sequence) in [ - (BapTaskState::Created, 1), - (BapTaskState::Admitted, 0), - (BapTaskState::Admitted, 2), - (BapTaskState::Running, 1), - (BapTaskState::Running, 3), - (BapTaskState::WaitingForApproval, 2), - (BapTaskState::WaitingForApproval, 4), - (BapTaskState::WaitingForExternalInput, 2), - (BapTaskState::WaitingForExternalInput, 4), - (BapTaskState::Checkpointed, 2), - (BapTaskState::Checkpointed, 4), - (BapTaskState::ReconciliationRequired, 2), - (BapTaskState::ReconciliationRequired, 4), - (BapTaskState::Succeeded, 2), - (BapTaskState::Succeeded, 4), - (BapTaskState::Failed, 0), - (BapTaskState::Cancelled, 0), - (BapTaskState::Expired, 0), - (BapTaskState::DeadLettered, 2), - ] { - assert_eq!( - BapTaskLifecycle::restore(state, sequence), - Err(BapTaskRestoreError::InvalidSnapshot { - state, - transition_sequence: sequence, - }), - "state={state:?}, sequence={sequence}", - ); - } -} - -#[test] -fn valid_restored_snapshot_classes_remain_accepted() { - for (state, sequence) in [ - (BapTaskState::Created, 0), - (BapTaskState::Admitted, 1), - (BapTaskState::Running, 2), - (BapTaskState::Running, 4), - (BapTaskState::WaitingForApproval, 3), - (BapTaskState::WaitingForExternalInput, 5), - (BapTaskState::Checkpointed, 7), - (BapTaskState::ReconciliationRequired, 3), - (BapTaskState::Succeeded, 3), - (BapTaskState::Failed, 1), - (BapTaskState::Cancelled, 2), - (BapTaskState::Expired, 4), - (BapTaskState::DeadLettered, 3), - (BapTaskState::DeadLettered, 4), - ] { - let task = BapTaskLifecycle::restore(state, sequence).expect("reachable snapshot"); - assert_eq!(task.state(), state); - assert_eq!(task.transition_sequence(), sequence); - } -} - -#[test] -fn exhausted_sequence_fails_closed_without_mutating_state() { - let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, u64::MAX) - .expect("valid exhausted checkpoint snapshot"); - - assert_eq!( - task.apply(BapTaskEvent::Resume), - Err(BapTaskTransitionError::SequenceExhausted), - ); - assert_eq!(task.state(), BapTaskState::Checkpointed); - assert_eq!(task.transition_sequence(), u64::MAX); -} - -#[test] -fn restored_terminal_lifecycle_remains_terminal() { - let mut task = - BapTaskLifecycle::restore(BapTaskState::Succeeded, 9).expect("valid terminal snapshot"); - - assert_eq!( - task.apply(BapTaskEvent::Resume), - Err(BapTaskTransitionError::TerminalState { - state: BapTaskState::Succeeded, - }), - ); - assert_eq!(task.transition_sequence(), 9); -} - -#[test] -fn lifecycle_failures_use_the_standard_rust_error_contract() { - let mut created = BapTaskLifecycle::new(); - let invalid_transition = created - .apply(BapTaskEvent::Start) - .expect_err("created task must reject start"); - assert_eq!( - invalid_transition.to_string(), - "BAP task event Start is invalid from state Created" - ); - assert!(invalid_transition.source().is_none()); - - let exhausted = BapTaskTransitionError::SequenceExhausted; - assert_eq!( - exhausted.to_string(), - "BAP task transition sequence is exhausted" - ); - assert!(exhausted.source().is_none()); - - let terminal = BapTaskTransitionError::TerminalState { - state: BapTaskState::Cancelled, - }; - assert_eq!(terminal.to_string(), "BAP task state Cancelled is terminal"); - assert!(terminal.source().is_none()); - - let restore = BapTaskLifecycle::restore(BapTaskState::Created, 1) - .expect_err("unreachable snapshot must fail"); - assert_eq!( - restore.to_string(), - "BAP task snapshot state Created with transition sequence 1 is unreachable" - ); - assert!(restore.source().is_none()); -} diff --git a/crates/originweave-core/Cargo.toml b/crates/originweave-core/Cargo.toml index dcda2a6c4..35c83b19b 100644 --- a/crates/originweave-core/Cargo.toml +++ b/crates/originweave-core/Cargo.toml @@ -10,11 +10,7 @@ repository.workspace = true homepage.workspace = true publish = false -[lib] -path = "src/root.rs" - [dependencies] -unicode-normalization = "=0.1.25" [lints] workspace = true diff --git a/crates/originweave-core/src/browser_authority_registry.rs b/crates/originweave-core/src/browser_authority_registry.rs new file mode 100644 index 000000000..3af93cb07 --- /dev/null +++ b/crates/originweave-core/src/browser_authority_registry.rs @@ -0,0 +1,163 @@ +use crate::browser_registry::BrowserAuthorityRegistry as RawBrowserAuthorityRegistry; +use crate::{ + BrowserRegistryError, BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHandle, + Origin, +}; + +/// Public browser-authority registry with raw node minting kept inside the crate. +/// +/// Browser-session, browsing-context, document-epoch, and canonical-origin lifecycle operations are +/// public because trusted adapters need them to maintain current authority. Converting untrusted +/// browser-protocol node identifiers into [`ObservedNodeHandle`] values is deliberately +/// crate-private: external callers must use a reviewed semantic-observation admission boundary such +/// as [`crate::WebDriverBiDiAccessibilityQuery::bind_current_nodes`], which consumes the required +/// protocol-use proof, validates the complete batch, and revalidates the exact current document +/// before atomically minting handles. +pub struct BrowserAuthorityRegistry { + inner: RawBrowserAuthorityRegistry, +} + +impl BrowserAuthorityRegistry { + /// Create an empty registry with the reviewed default per-namespace identifier capacity. + #[must_use] + pub fn new() -> Self { + Self { + inner: RawBrowserAuthorityRegistry::new(), + } + } + + /// Create an empty registry with a caller-selected per-namespace identifier capacity. + /// + /// Session, browsing-context, and node identifiers retain independent monotonic namespaces. + /// The node namespace is still reachable only through crate-owned semantic admission. + #[must_use] + pub fn with_identifier_limit(maximum_identifier: u64) -> Self { + Self { + inner: RawBrowserAuthorityRegistry::with_identifier_limit(maximum_identifier), + } + } + + /// Register one opaque external browser-session identifier. + pub fn register_session( + &mut self, + external_identifier: &str, + ) -> Result { + self.inner.register_session(external_identifier) + } + + /// Register one opaque external browsing-context identifier inside a known browser session. + pub fn register_context( + &mut self, + browser_session: BrowserSessionId, + external_identifier: &str, + ) -> Result { + self.inner + .register_context(browser_session, external_identifier) + } + + /// Retire one browsing context and all registry-local authority derived from it. + pub fn remove_context( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result<(), BrowserRegistryError> { + self.inner.remove_context(browsing_context) + } + + /// Retire one browser session and every registered context and node binding beneath it. + pub fn remove_session( + &mut self, + browser_session: BrowserSessionId, + ) -> Result<(), BrowserRegistryError> { + self.inner.remove_session(browser_session) + } + + /// Return the currently active document epoch for a known browsing context. + pub fn current_epoch( + &self, + browsing_context: BrowsingContextId, + ) -> Result { + self.inner.current_epoch(browsing_context) + } + + /// Return the current document epoch only when the supplied session owns the context. + pub fn current_context_epoch( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + ) -> Result { + self.inner + .current_context_epoch(browser_session, browsing_context) + } + + /// Require an opaque external browsing-context identifier to name this exact context. + pub(crate) fn require_context_external_identifier( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + external_identifier: &str, + ) -> Result<(), BrowserRegistryError> { + self.inner.require_context_external_identifier( + browser_session, + browsing_context, + external_identifier, + ) + } + + /// Bind the canonical origin observed for the exact current browser document. + pub fn bind_context_origin( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + ) -> Result { + self.inner + .bind_context_origin(browser_session, browsing_context, origin) + } + + /// Revalidate the canonical origin bound to the exact current browser document. + pub fn require_context_origin( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + ) -> Result { + self.inner + .require_context_origin(browser_session, browsing_context, origin) + } + + /// Advance a browsing context to the next document epoch and invalidate old node bindings. + pub fn advance_document( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + self.inner.advance_document(browsing_context) + } + + /// Bind one admitted batch of adapter-local node identifiers to current browser authority. + /// + /// This operation is intentionally crate-private. The raw registry commits the batch only when + /// every identifier can be bound; a later failure rolls back node identifiers and any origin + /// binding created by the batch before the error is returned. Production callers outside this + /// crate therefore cannot bypass semantic admission or observe partial authority from a failed + /// `locateNodes` result. + pub(crate) fn bind_nodes( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifiers: &[&str], + ) -> Result, BrowserRegistryError> { + self.inner.bind_nodes( + browser_session, + browsing_context, + origin, + external_identifiers, + ) + } +} + +impl Default for BrowserAuthorityRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/originweave-core/src/browser_protocol.rs b/crates/originweave-core/src/browser_protocol.rs new file mode 100644 index 000000000..3e30eccfe --- /dev/null +++ b/crates/originweave-core/src/browser_protocol.rs @@ -0,0 +1,586 @@ +use std::{fmt, str::FromStr}; + +/// Maximum UTF-8 byte length for browser protocol adapter metadata tokens. +pub const MAX_BROWSER_PROTOCOL_METADATA_BYTES: usize = 128; + +/// One OriginWeave Protocol generation. +/// +/// This value identifies the OriginWeave contract spoken by an adapter. It is +/// deliberately independent from the upstream WebDriver BiDi/CDP revision and +/// from the browser build. Constructing a version does not make that version +/// supported; callers must compare it with the exact version required by the +/// surrounding OriginWeave protocol boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OriginWeaveProtocolVersion { + major: u16, + minor: u16, +} + +impl OriginWeaveProtocolVersion { + /// Construct an OriginWeave Protocol generation identifier. + #[must_use] + pub const fn new(major: u16, minor: u16) -> Self { + Self { major, minor } + } + + /// Return the protocol major version. + #[must_use] + pub const fn major(self) -> u16 { + self.major + } + + /// Return the protocol minor version. + #[must_use] + pub const fn minor(self) -> u16 { + self.minor + } +} + +impl fmt::Display for OriginWeaveProtocolVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "originweave/{}.{}", self.major, self.minor) + } +} + +impl FromStr for OriginWeaveProtocolVersion { + type Err = OriginWeaveProtocolVersionParseError; + + fn from_str(value: &str) -> Result { + let Some(remainder) = value.strip_prefix("originweave/") else { + return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); + }; + let Some((major_text, minor_text)) = remainder.split_once('.') else { + return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); + }; + if minor_text.contains('.') { + return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); + } + let Ok(major) = major_text.parse::() else { + return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); + }; + let Ok(minor) = minor_text.parse::() else { + return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); + }; + + let version = Self::new(major, minor); + if version.to_string() != value { + return Err(OriginWeaveProtocolVersionParseError::InvalidFormat); + } + Ok(version) + } +} + +/// Failure to parse a canonical serialized OriginWeave Protocol generation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OriginWeaveProtocolVersionParseError { + /// The value did not use the exact canonical `originweave/.` syntax. + InvalidFormat, +} + +impl fmt::Display for OriginWeaveProtocolVersionParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFormat => formatter.write_str( + "OriginWeave protocol version must use canonical originweave/. syntax", + ), + } + } +} + +impl std::error::Error for OriginWeaveProtocolVersionParseError {} + +/// Browser automation protocol family used by one versioned adapter. +/// +/// The protocol family is descriptive metadata only. Selecting a kind does not +/// grant any OriginWeave capability or imply that a particular protocol +/// feature is available. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolKind { + /// Standards-track WebDriver BiDi adapter. + WebDriverBiDi, + /// Chromium-specific Chrome DevTools Protocol adapter. + ChromeDevToolsProtocol, +} + +/// One browser operation surface explicitly implemented by an adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolCapability { + /// Navigate a controlled browser context through the adapter. + Navigation, + /// Produce bounded semantic browser observations. + SemanticObservation, + /// Dispatch typed browser input after OriginWeave policy authorization. + TypedInput, + /// Observe bounded network evidence needed by higher-level provenance. + NetworkObservation, +} + +/// Immutable version and capability metadata for one browser protocol adapter. +/// +/// This value is deliberately not browser authority. It contains no browser +/// session, context, origin, node handle, action grant, credential, or network +/// permission. Higher layers may use it to fail closed when the adapter targets +/// the wrong OriginWeave Protocol generation or lacks a required browser +/// capability, while all OriginWeave authority remains separately validated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserProtocolAdapterDescriptor { + kind: BrowserProtocolKind, + originweave_protocol_version: OriginWeaveProtocolVersion, + adapter_version: String, + protocol_revision: String, + browser_revision: String, + capabilities: Vec, +} + +impl BrowserProtocolAdapterDescriptor { + /// Construct one explicit adapter descriptor. + /// + /// The OriginWeave Protocol generation, adapter version, upstream protocol + /// revision, and browser revision are distinct metadata. This prevents an + /// OriginWeave contract version from being mistaken for the WebDriver + /// BiDi/CDP revision or the pinned browser build it was validated against. + /// The declared capability list must be non-empty and duplicate-free and is + /// normalized into one stable order so caller ordering cannot change + /// descriptor identity. + pub fn new( + kind: BrowserProtocolKind, + originweave_protocol_version: OriginWeaveProtocolVersion, + adapter_version: &str, + protocol_revision: &str, + browser_revision: &str, + capabilities: &[BrowserProtocolCapability], + ) -> Result { + if !metadata_token_is_valid(adapter_version) { + return Err(BrowserProtocolDescriptorError::InvalidAdapterVersion); + } + if !metadata_token_is_valid(protocol_revision) { + return Err(BrowserProtocolDescriptorError::InvalidProtocolRevision); + } + if !metadata_token_is_valid(browser_revision) { + return Err(BrowserProtocolDescriptorError::InvalidBrowserRevision); + } + if capabilities.is_empty() { + return Err(BrowserProtocolDescriptorError::EmptyCapabilities); + } + + let mut canonical_capabilities = Vec::with_capacity(capabilities.len()); + for capability in capabilities { + if canonical_capabilities.contains(capability) { + return Err(BrowserProtocolDescriptorError::DuplicateCapability); + } + canonical_capabilities.push(*capability); + } + canonical_capabilities.sort_unstable_by_key(|capability| capability_rank(*capability)); + + Ok(Self { + kind, + originweave_protocol_version, + adapter_version: adapter_version.to_owned(), + protocol_revision: protocol_revision.to_owned(), + browser_revision: browser_revision.to_owned(), + capabilities: canonical_capabilities, + }) + } + + /// Return the explicitly declared browser protocol family. + #[must_use] + pub const fn kind(&self) -> BrowserProtocolKind { + self.kind + } + + /// Return the exact OriginWeave Protocol generation implemented by this adapter. + #[must_use] + pub const fn originweave_protocol_version(&self) -> OriginWeaveProtocolVersion { + self.originweave_protocol_version + } + + /// Return the bounded OriginWeave adapter-version metadata token. + #[must_use] + pub fn adapter_version(&self) -> &str { + &self.adapter_version + } + + /// Return the bounded upstream browser-protocol revision metadata token. + #[must_use] + pub fn protocol_revision(&self) -> &str { + &self.protocol_revision + } + + /// Return the bounded pinned browser-revision metadata token. + #[must_use] + pub fn browser_revision(&self) -> &str { + &self.browser_revision + } + + /// Return the number of explicitly declared capabilities. + #[must_use] + pub fn capability_count(&self) -> usize { + self.capabilities.len() + } + + /// Return whether this descriptor explicitly declares one capability. + #[must_use] + pub fn supports(&self, capability: BrowserProtocolCapability) -> bool { + self.capabilities.contains(&capability) + } + + /// Require one exact OriginWeave Protocol generation before later adapter use. + /// + /// Pre-alpha compatibility is deliberately exact at this boundary. A caller + /// may add a separately reviewed compatibility transform later, but this + /// descriptor never silently treats a different major or minor generation + /// as equivalent. + pub fn require_originweave_protocol_version( + &self, + required: OriginWeaveProtocolVersion, + ) -> Result<(), BrowserProtocolVersionRequirementError> { + if self.originweave_protocol_version == required { + Ok(()) + } else { + Err( + BrowserProtocolVersionRequirementError::ProtocolVersionMismatch { + required, + actual: self.originweave_protocol_version, + }, + ) + } + } + + /// Require exact runtime browser-protocol and browser revisions before use. + /// + /// The caller must derive both values from the trusted runtime adapter that + /// is about to perform browser work. This deterministic comparison does not + /// authenticate or attest that caller. It only prevents a descriptor pinned + /// to one validated upstream-protocol/browser pair from being silently used + /// when the supplied runtime evidence is malformed or has drifted. + pub fn require_runtime_revisions( + &self, + protocol_revision: &str, + browser_revision: &str, + ) -> Result<(), BrowserProtocolRuntimeRequirementError> { + if !metadata_token_is_valid(protocol_revision) { + return Err(BrowserProtocolRuntimeRequirementError::InvalidProtocolRevision); + } + if !metadata_token_is_valid(browser_revision) { + return Err(BrowserProtocolRuntimeRequirementError::InvalidBrowserRevision); + } + if self.protocol_revision != protocol_revision { + return Err(BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch); + } + if self.browser_revision != browser_revision { + return Err(BrowserProtocolRuntimeRequirementError::BrowserRevisionMismatch); + } + Ok(()) + } + + /// Require one explicitly declared adapter capability before later use. + /// + /// This method never infers support from the browser protocol family. An + /// absent capability fails closed with a typed error so a caller cannot + /// silently fall back to another upstream protocol or a raw browser escape + /// hatch merely because the selected adapter lacks the requested surface. + pub fn require_capability( + &self, + capability: BrowserProtocolCapability, + ) -> Result<(), BrowserProtocolCapabilityRequirementError> { + if self.supports(capability) { + Ok(()) + } else { + Err(BrowserProtocolCapabilityRequirementError::UnsupportedCapability(capability)) + } + } + + /// Validate all adapter metadata prerequisites for one immediate browser operation. + /// + /// Validation is intentionally ordered and fail closed: the exact + /// OriginWeave Protocol generation is checked first, then the caller-supplied + /// runtime protocol family, runtime adapter version, protocol/browser + /// revisions, and finally the required adapter capability. Success returns + /// a non-cloneable value that a later trusted transport can consume as proof + /// that these metadata prerequisites were checked together. It is not + /// browser or Agent authority and does not authenticate or attest the caller + /// supplying runtime metadata. + pub fn validate_use( + &self, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_kind: BrowserProtocolKind, + runtime_adapter_version: &str, + runtime_protocol_revision: &str, + runtime_browser_revision: &str, + required_capability: BrowserProtocolCapability, + ) -> Result { + self.require_originweave_protocol_version(required_originweave_protocol_version) + .map_err(BrowserProtocolUseValidationError::ProtocolVersion)?; + if self.kind != runtime_kind { + return Err(BrowserProtocolUseValidationError::ProtocolKindMismatch { + descriptor_kind: self.kind, + runtime_kind, + }); + } + if !metadata_token_is_valid(runtime_adapter_version) { + return Err(BrowserProtocolUseValidationError::InvalidAdapterVersion); + } + if self.adapter_version != runtime_adapter_version { + return Err(BrowserProtocolUseValidationError::AdapterVersionMismatch); + } + self.require_runtime_revisions(runtime_protocol_revision, runtime_browser_revision) + .map_err(BrowserProtocolUseValidationError::RuntimeRevision)?; + self.require_capability(required_capability) + .map_err(BrowserProtocolUseValidationError::Capability)?; + + Ok(ValidatedBrowserProtocolUse { + descriptor: self.clone(), + capability: required_capability, + }) + } +} + +/// Snapshot proving that one descriptor passed all browser-protocol metadata checks for one use. +/// +/// Only [`BrowserProtocolAdapterDescriptor::validate_use`] can construct this +/// value. It intentionally does not implement `Clone`: a future trusted browser +/// transport can consume the value by ownership at the operation boundary +/// rather than treating it as reusable ambient authority. The value still does +/// not authenticate an adapter or attest that supplied runtime metadata came +/// from the running browser process. +#[derive(Debug, PartialEq, Eq)] +pub struct ValidatedBrowserProtocolUse { + descriptor: BrowserProtocolAdapterDescriptor, + capability: BrowserProtocolCapability, +} + +impl ValidatedBrowserProtocolUse { + /// Return the validated browser protocol family. + #[must_use] + pub const fn kind(&self) -> BrowserProtocolKind { + self.descriptor.kind + } + + /// Return the validated OriginWeave Protocol generation. + #[must_use] + pub const fn originweave_protocol_version(&self) -> OriginWeaveProtocolVersion { + self.descriptor.originweave_protocol_version + } + + /// Return the validated bounded adapter-version metadata token. + #[must_use] + pub fn adapter_version(&self) -> &str { + &self.descriptor.adapter_version + } + + /// Return the validated bounded upstream protocol-revision metadata token. + #[must_use] + pub fn protocol_revision(&self) -> &str { + &self.descriptor.protocol_revision + } + + /// Return the validated bounded browser-revision metadata token. + #[must_use] + pub fn browser_revision(&self) -> &str { + &self.descriptor.browser_revision + } + + /// Return the exact adapter capability validated for this use. + #[must_use] + pub const fn capability(&self) -> BrowserProtocolCapability { + self.capability + } +} + +const fn capability_rank(capability: BrowserProtocolCapability) -> u8 { + match capability { + BrowserProtocolCapability::Navigation => 0, + BrowserProtocolCapability::SemanticObservation => 1, + BrowserProtocolCapability::TypedInput => 2, + BrowserProtocolCapability::NetworkObservation => 3, + } +} + +fn capability_name(capability: BrowserProtocolCapability) -> &'static str { + match capability { + BrowserProtocolCapability::Navigation => "navigation", + BrowserProtocolCapability::SemanticObservation => "semantic-observation", + BrowserProtocolCapability::TypedInput => "typed-input", + BrowserProtocolCapability::NetworkObservation => "network-observation", + } +} + +fn metadata_token_is_valid(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_BROWSER_PROTOCOL_METADATA_BYTES + && value.is_ascii() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) +} + +/// Failure to require one exact OriginWeave Protocol generation from an adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolVersionRequirementError { + /// The adapter targets a different OriginWeave Protocol generation. + ProtocolVersionMismatch { + /// Exact OriginWeave Protocol generation required by the caller. + required: OriginWeaveProtocolVersion, + /// Exact OriginWeave Protocol generation declared by the adapter. + actual: OriginWeaveProtocolVersion, + }, +} + +impl fmt::Display for BrowserProtocolVersionRequirementError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ProtocolVersionMismatch { required, actual } => write!( + formatter, + "browser protocol adapter targets {actual} but {required} is required" + ), + } + } +} + +impl std::error::Error for BrowserProtocolVersionRequirementError {} + +/// Failure to require exact pinned runtime revision evidence from an adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolRuntimeRequirementError { + /// The runtime upstream-protocol revision token was malformed. + InvalidProtocolRevision, + /// The runtime browser revision token was malformed. + InvalidBrowserRevision, + /// The runtime upstream-protocol revision differs from the pinned descriptor. + ProtocolRevisionMismatch, + /// The runtime browser revision differs from the pinned descriptor. + BrowserRevisionMismatch, +} + +impl fmt::Display for BrowserProtocolRuntimeRequirementError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidProtocolRevision => formatter.write_str( + "runtime browser protocol revision must be a bounded ASCII metadata token", + ), + Self::InvalidBrowserRevision => formatter + .write_str("runtime browser revision must be a bounded ASCII metadata token"), + Self::ProtocolRevisionMismatch => formatter.write_str( + "runtime browser protocol revision does not match the pinned adapter revision", + ), + Self::BrowserRevisionMismatch => formatter.write_str( + "runtime browser revision does not match the pinned adapter browser revision", + ), + } + } +} + +impl std::error::Error for BrowserProtocolRuntimeRequirementError {} + +/// Failure to require one browser protocol capability from an adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolCapabilityRequirementError { + /// The adapter did not explicitly declare the required capability. + UnsupportedCapability(BrowserProtocolCapability), +} + +impl fmt::Display for BrowserProtocolCapabilityRequirementError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedCapability(capability) => write!( + formatter, + "browser protocol adapter does not declare required {} capability", + capability_name(*capability) + ), + } + } +} + +impl std::error::Error for BrowserProtocolCapabilityRequirementError {} + +/// Failure to validate all browser-protocol metadata prerequisites for one use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolUseValidationError { + /// The descriptor targets the wrong OriginWeave Protocol generation. + ProtocolVersion(BrowserProtocolVersionRequirementError), + /// The runtime transport reports a different protocol family than the descriptor. + ProtocolKindMismatch { + /// Browser protocol family pinned by the adapter descriptor. + descriptor_kind: BrowserProtocolKind, + /// Browser protocol family reported by the runtime transport. + runtime_kind: BrowserProtocolKind, + }, + /// The runtime adapter-version token was malformed. + InvalidAdapterVersion, + /// The runtime adapter version differs from the pinned descriptor version. + AdapterVersionMismatch, + /// The supplied runtime protocol or browser revision is invalid or has drifted. + RuntimeRevision(BrowserProtocolRuntimeRequirementError), + /// The descriptor does not explicitly declare the required capability. + Capability(BrowserProtocolCapabilityRequirementError), +} + +impl fmt::Display for BrowserProtocolUseValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ProtocolVersion(error) => error.fmt(formatter), + Self::ProtocolKindMismatch { .. } => formatter + .write_str("runtime browser protocol kind does not match the pinned adapter kind"), + Self::InvalidAdapterVersion => formatter.write_str( + "runtime browser adapter version must be a bounded ASCII metadata token", + ), + Self::AdapterVersionMismatch => formatter.write_str( + "runtime browser adapter version does not match the pinned adapter version", + ), + Self::RuntimeRevision(error) => error.fmt(formatter), + Self::Capability(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for BrowserProtocolUseValidationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ProtocolVersion(error) => Some(error), + Self::ProtocolKindMismatch { .. } + | Self::InvalidAdapterVersion + | Self::AdapterVersionMismatch => None, + Self::RuntimeRevision(error) => Some(error), + Self::Capability(error) => Some(error), + } + } +} + +/// Failure to construct canonical browser protocol adapter metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolDescriptorError { + /// The adapter-version token was empty, oversized, non-ASCII, or malformed. + InvalidAdapterVersion, + /// The upstream protocol-revision token was empty, oversized, non-ASCII, or malformed. + InvalidProtocolRevision, + /// The browser-revision token was empty, oversized, non-ASCII, or malformed. + InvalidBrowserRevision, + /// The adapter declared no supported browser capability. + EmptyCapabilities, + /// The adapter declared the same capability more than once. + DuplicateCapability, +} + +impl fmt::Display for BrowserProtocolDescriptorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidAdapterVersion => formatter.write_str( + "browser protocol adapter version must be a bounded ASCII metadata token", + ), + Self::InvalidProtocolRevision => formatter + .write_str("browser protocol revision must be a bounded ASCII metadata token"), + Self::InvalidBrowserRevision => { + formatter.write_str("browser revision must be a bounded ASCII metadata token") + } + Self::EmptyCapabilities => { + formatter.write_str("browser protocol adapter must declare at least one capability") + } + Self::DuplicateCapability => { + formatter.write_str("browser protocol adapter capabilities must be unique") + } + } + } +} + +impl std::error::Error for BrowserProtocolDescriptorError {} diff --git a/crates/originweave-core/src/browser_protocol_dispatch.rs b/crates/originweave-core/src/browser_protocol_dispatch.rs new file mode 100644 index 000000000..14bafb3d6 --- /dev/null +++ b/crates/originweave-core/src/browser_protocol_dispatch.rs @@ -0,0 +1,365 @@ +use std::fmt; + +use crate::{ + BrowserAuthorityRegistry, BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolKind, BrowserProtocolUseValidationError, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, +}; + +/// Current runtime metadata sampled from the browser-protocol adapter about to perform I/O. +/// +/// This value is untrusted descriptive input. Constructing it does not validate or authenticate an +/// adapter, browser, or protocol revision and grants no browser or Agent authority. The descriptor +/// validates every field against its reviewed metadata before a dispatch callback can run. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BrowserProtocolRuntimeMetadata<'a> { + kind: BrowserProtocolKind, + adapter_version: &'a str, + protocol_revision: &'a str, + browser_revision: &'a str, +} + +impl<'a> BrowserProtocolRuntimeMetadata<'a> { + /// Build one runtime metadata snapshot for immediate validation and dispatch. + /// + /// String syntax and descriptor equality are intentionally checked later by + /// [`BrowserProtocolAdapterDescriptor::dispatch_if_runtime_matches`], so malformed caller data + /// remains representable as input that the fail-closed boundary can reject deterministically. + pub const fn new( + kind: BrowserProtocolKind, + adapter_version: &'a str, + protocol_revision: &'a str, + browser_revision: &'a str, + ) -> Self { + Self { + kind, + adapter_version, + protocol_revision, + browser_revision, + } + } +} + +/// Exact OriginWeave browser session/context requested for one immediate protocol dispatch. +/// +/// This value only keeps the two identifiers together so a caller cannot accidentally reorder or +/// independently substitute them at the dispatch boundary. Constructing or copying it does not +/// prove that either identifier is registered, current, or authorized; the authority registry must +/// validate the pair immediately before protocol metadata validation and callback invocation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BrowserContextDispatchTarget { + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, +} + +impl BrowserContextDispatchTarget { + /// Group one OriginWeave browser session and browsing context for immediate dispatch checking. + #[must_use] + pub const fn new( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + ) -> Self { + Self { + browser_session, + browsing_context, + } + } + + /// Return the OriginWeave browser session requested for this dispatch. + #[must_use] + pub const fn browser_session(self) -> BrowserSessionId { + self.browser_session + } + + /// Return the OriginWeave browsing context requested for this dispatch. + #[must_use] + pub const fn browsing_context(self) -> BrowsingContextId { + self.browsing_context + } +} + +/// Exact browser context plus the canonical origin expected immediately before protocol dispatch. +/// +/// Grouping these values keeps one authority target explicit while avoiding a long positional +/// argument list at the dispatch boundary. Construction does not prove that the context is current +/// or that the origin is bound; [`BrowserProtocolAdapterDescriptor::dispatch_if_context_origin_current`] +/// performs those fail-closed checks immediately before protocol validation and callback execution. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BrowserContextOriginDispatchTarget<'a> { + context: BrowserContextDispatchTarget, + expected_origin: &'a Origin, +} + +impl<'a> BrowserContextOriginDispatchTarget<'a> { + /// Group one browser context target with its freshly sampled canonical origin. + #[must_use] + pub const fn new(context: BrowserContextDispatchTarget, expected_origin: &'a Origin) -> Self { + Self { + context, + expected_origin, + } + } + + /// Return the exact browser session/context pair requested for dispatch. + #[must_use] + pub const fn context(self) -> BrowserContextDispatchTarget { + self.context + } + + /// Return the canonical origin expected to remain current for the dispatch. + #[must_use] + pub const fn expected_origin(self) -> &'a Origin { + self.expected_origin + } +} + +/// Exact browser context, canonical origin, and observed document epoch for one protocol dispatch. +/// +/// This target is intended for actions whose authority was derived from a prior structured browser +/// observation. Construction grants no authority. The dispatch boundary must revalidate the +/// session/context/origin and prove that the registry is still at `expected_epoch` immediately +/// before protocol metadata validation and callback execution. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BrowserContextOriginEpochDispatchTarget<'a> { + context_origin: BrowserContextOriginDispatchTarget<'a>, + expected_epoch: DocumentEpoch, +} + +impl<'a> BrowserContextOriginEpochDispatchTarget<'a> { + /// Bind one immediate-use context/origin target to the document epoch that was observed. + #[must_use] + pub const fn new( + context_origin: BrowserContextOriginDispatchTarget<'a>, + expected_epoch: DocumentEpoch, + ) -> Self { + Self { + context_origin, + expected_epoch, + } + } + + /// Return the exact browser context and canonical origin requested for dispatch. + #[must_use] + pub const fn context_origin(self) -> BrowserContextOriginDispatchTarget<'a> { + self.context_origin + } + + /// Return the exact document epoch whose observation authorized the requested action. + #[must_use] + pub const fn expected_epoch(self) -> DocumentEpoch { + self.expected_epoch + } +} + +impl BrowserProtocolAdapterDescriptor { + /// Validate current browser-protocol metadata and immediately invoke one dispatch callback. + /// + /// `runtime_metadata` must be sampled from the trusted adapter that is about to perform the + /// operation. Validation occurs before `dispatch` is invoked, and the callback receives the + /// resulting non-cloneable [`ValidatedBrowserProtocolUse`] by ownership so this boundary does + /// not turn successful validation into reusable ambient authority. + /// + /// A successful callback invocation does not authenticate the adapter process, authorize a + /// browser session, browsing context, origin, destination, secret, or approval, or prove a + /// browser post-condition. Those remain separate higher-level execution boundaries. + pub fn dispatch_if_runtime_matches( + &self, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + required_capability: BrowserProtocolCapability, + dispatch: F, + ) -> Result + where + F: FnOnce(ValidatedBrowserProtocolUse) -> R, + { + let validated = self.validate_use( + required_originweave_protocol_version, + runtime_metadata.kind, + runtime_metadata.adapter_version, + runtime_metadata.protocol_revision, + runtime_metadata.browser_revision, + required_capability, + )?; + Ok(dispatch(validated)) + } + + /// Revalidate exact browser session/context ownership and runtime metadata before dispatch. + /// + /// The registry check occurs first and returns its current document epoch. The exact protocol + /// generation, runtime protocol family, adapter version, upstream/browser revisions, and + /// required capability are then validated before `dispatch` can run. The callback receives the + /// non-cloneable protocol-use proof plus the registry epoch sampled for this immediate use. + /// + /// This is a composition prerequisite, not complete browser-action authority. In particular, + /// typed input still requires separate current origin/document/node and deterministic policy + /// authorization, while navigation still requires destination/network/TLS/HTTP authority. + /// The caller remains responsible for sampling runtime metadata from the adapter about to + /// perform I/O and for preventing registry mutation across its larger execution transaction. + pub fn dispatch_if_context_current( + &self, + authority_registry: &BrowserAuthorityRegistry, + target: BrowserContextDispatchTarget, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + required_capability: BrowserProtocolCapability, + dispatch: F, + ) -> Result + where + F: FnOnce(ValidatedBrowserProtocolUse, DocumentEpoch) -> R, + { + let current_epoch = authority_registry + .current_context_epoch(target.browser_session(), target.browsing_context()) + .map_err(BrowserContextProtocolDispatchError::BrowserAuthority)?; + self.dispatch_if_runtime_matches( + required_originweave_protocol_version, + runtime_metadata, + required_capability, + |validated| dispatch(validated, current_epoch), + ) + .map_err(BrowserContextProtocolDispatchError::ProtocolValidation) + } + + /// Revalidate exact browser session/context/origin authority and protocol metadata before I/O. + /// + /// The registry first proves that `target.expected_origin()` is the canonical origin currently + /// bound to the supplied browser session and browsing context and returns that document's + /// current epoch. Only then are the exact protocol generation, runtime protocol family, adapter + /// version, upstream/browser revisions, and required capability validated. `dispatch` receives + /// both the non-cloneable protocol-use proof and the epoch sampled by that origin revalidation. + /// + /// This method does not derive the origin from Chromium, authenticate the adapter process, + /// authorize a destination/network/TLS/HTTP operation, grant Agent capability or approval, or + /// prove a post-condition. The caller must construct `target` from the origin freshly sampled + /// from the trusted adapter about to perform I/O, sample `runtime_metadata` from that same + /// adapter, and prevent intervening registry mutation across its larger execution transaction. + pub fn dispatch_if_context_origin_current( + &self, + authority_registry: &BrowserAuthorityRegistry, + target: BrowserContextOriginDispatchTarget<'_>, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + required_capability: BrowserProtocolCapability, + dispatch: F, + ) -> Result + where + F: FnOnce(ValidatedBrowserProtocolUse, DocumentEpoch) -> R, + { + let context = target.context(); + let current_epoch = authority_registry + .require_context_origin( + context.browser_session(), + context.browsing_context(), + target.expected_origin(), + ) + .map_err(BrowserContextProtocolDispatchError::BrowserAuthority)?; + self.dispatch_if_runtime_matches( + required_originweave_protocol_version, + runtime_metadata, + required_capability, + |validated| dispatch(validated, current_epoch), + ) + .map_err(BrowserContextProtocolDispatchError::ProtocolValidation) + } + + /// Revalidate exact browser session/context/origin/document authority before protocol I/O. + /// + /// This stronger action boundary first proves the exact current session/context/origin through + /// the authority registry, then compares the registry's current document epoch with the epoch + /// that produced the caller's observation. A same-origin navigation therefore fails closed + /// before protocol validation or callback execution even when the canonical origin is rebound. + /// Exact protocol generation, family, adapter version, protocol/browser revisions and required + /// capability are validated only after the document remains current. + /// + /// The caller remains responsible for deriving the origin and observed epoch from the trusted + /// adapter/observation that produced the action, sampling runtime protocol metadata from the + /// adapter about to perform I/O, and preventing intervening mutation across the larger + /// transaction. This method does not authenticate Chromium, authorize destination/network + /// authority or policy approval, validate semantic node state, perform I/O, or prove success. + pub fn dispatch_if_context_origin_epoch_current( + &self, + authority_registry: &BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + required_capability: BrowserProtocolCapability, + dispatch: F, + ) -> Result + where + F: FnOnce(ValidatedBrowserProtocolUse, DocumentEpoch) -> R, + { + let context_origin = target.context_origin(); + let context = context_origin.context(); + let current_epoch = authority_registry + .require_context_origin( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + ) + .map_err(BrowserContextProtocolDispatchError::BrowserAuthority)?; + if current_epoch != target.expected_epoch() { + return Err(BrowserContextProtocolDispatchError::DocumentEpochMismatch { + expected: target.expected_epoch(), + current: current_epoch, + }); + } + self.dispatch_if_runtime_matches( + required_originweave_protocol_version, + runtime_metadata, + required_capability, + |validated| dispatch(validated, current_epoch), + ) + .map_err(BrowserContextProtocolDispatchError::ProtocolValidation) + } +} + +/// Failure to compose current browser context ownership with protocol validation before dispatch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserContextProtocolDispatchError { + /// The supplied browser session/context pair is not current in the authority registry. + BrowserAuthority(BrowserRegistryError), + /// The observed document epoch no longer matches the registry's current document. + DocumentEpochMismatch { + /// The document epoch that produced the action's observation. + expected: DocumentEpoch, + /// The document epoch currently active in the registry. + current: DocumentEpoch, + }, + /// The current browser-protocol metadata or required capability failed validation. + ProtocolValidation(BrowserProtocolUseValidationError), +} + +impl fmt::Display for BrowserContextProtocolDispatchError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BrowserAuthority(error) => { + write!( + formatter, + "browser context authority denied protocol dispatch: {error}" + ) + } + Self::DocumentEpochMismatch { expected, current } => write!( + formatter, + "browser document epoch {} no longer matches observed epoch {}", + current.value(), + expected.value() + ), + Self::ProtocolValidation(error) => { + write!( + formatter, + "browser protocol validation denied context dispatch: {error}" + ) + } + } + } +} + +impl std::error::Error for BrowserContextProtocolDispatchError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::BrowserAuthority(error) => Some(error), + Self::DocumentEpochMismatch { .. } => None, + Self::ProtocolValidation(error) => Some(error), + } + } +} diff --git a/crates/originweave-core/src/browser_protocol_operation.rs b/crates/originweave-core/src/browser_protocol_operation.rs new file mode 100644 index 000000000..280be51c1 --- /dev/null +++ b/crates/originweave-core/src/browser_protocol_operation.rs @@ -0,0 +1,610 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use crate::{ + BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, + BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolRuntimeMetadata, + BrowserRegistryError, DocumentEpoch, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, ObservedNodeHandle, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, +}; + +/// Exact WebDriver BiDi method used by the bounded accessibility-query contract. +pub const WEBDRIVER_BIDI_LOCATE_NODES_METHOD: &str = "browsingContext.locateNodes"; +/// Maximum UTF-8 bytes accepted for one accessibility-role query value. +pub const MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES: usize = 64; +/// Maximum UTF-8 bytes accepted for one accessibility-name query value. +pub const MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES: usize = 512; +/// Maximum number of nodes one bounded accessibility query may request. +pub const MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT: u16 = 128; +/// Fixed DOM serialization depth for the first bounded BiDi node-query slice. +pub const WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH: u16 = 0; +/// Fixed object serialization depth for the first bounded BiDi node-query slice. +pub const WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH: u16 = 0; +/// Fixed shadow-tree serialization mode for the first bounded BiDi node-query slice. +pub const WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE: &str = "none"; + +/// Fail-closed validation errors for one bounded WebDriver BiDi accessibility query. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiAccessibilityQueryError { + /// Neither an accessibility role nor an accessible name was supplied. + MissingLocatorValue, + /// An explicitly supplied accessibility role was empty. + EmptyRole, + /// The accessibility role exceeded the local UTF-8 byte budget. + RoleTooLong, + /// An explicitly supplied accessible name was empty. + EmptyName, + /// The accessibility role contained whitespace, a control, or a Unicode format character. + InvalidRole, + /// The accessible name contained a control, Unicode format character, or only whitespace. + InvalidName, + /// The accessible name exceeded the local UTF-8 byte budget. + NameTooLong, + /// The requested node count was zero or exceeded the local result budget. + InvalidNodeCount, + /// The untrusted adapter returned more nodes than the reviewed request budget allowed. + ResultNodeCountExceeded, +} + +impl Display for WebDriverBiDiAccessibilityQueryError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::MissingLocatorValue => "accessibility query requires a role or accessible name", + Self::EmptyRole => "accessibility query role must not be empty", + Self::RoleTooLong => "accessibility query role exceeds the local byte budget", + Self::EmptyName => "accessibility query name must not be empty", + Self::InvalidRole => { + "accessibility query role must not contain whitespace, control, or Unicode format characters" + } + Self::InvalidName => { + "accessibility query name must not contain control or Unicode format characters or only whitespace" + } + Self::NameTooLong => "accessibility query name exceeds the local byte budget", + Self::InvalidNodeCount => "accessibility query node count is outside the local budget", + Self::ResultNodeCountExceeded => { + "accessibility query result exceeds the requested node budget" + } + }; + formatter.write_str(message) + } +} + +impl Error for WebDriverBiDiAccessibilityQueryError {} + +/// Bounded transport parameters for WebDriver BiDi accessibility-node lookup. +/// +/// This value captures only the reviewed `browsingContext.locateNodes` accessibility-locator +/// parameters needed by the first Chromium observation slice. It accepts an exact role, an exact +/// accessible name, or both, together with a finite result count. Roles are exact tokens and +/// therefore reject whitespace, controls, and Unicode format characters. Accessible names may +/// contain ordinary spaces but reject controls, format characters, and whitespace-only values. +/// Text budgets are OriginWeave resource limits rather +/// than claims about upstream protocol maxima. +/// +/// The first slice also fixes WebDriver BiDi serialization to zero DOM depth, zero object depth, +/// and no shadow-tree expansion. Those settings intentionally minimize the remote-value surface a +/// future transport adapter may request. The adapter must additionally revalidate the returned +/// node count against this exact query before it retains or normalizes any returned node data. +/// +/// Construction grants no browser session, context, origin, semantic-node, policy, capability, or +/// network authority and performs no browser I/O. A trusted adapter must still bind the query to an +/// exact current browsing context through the separately reviewed authority and protocol boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebDriverBiDiAccessibilityQuery { + role: Option, + name: Option, + max_node_count: u16, +} + +impl WebDriverBiDiAccessibilityQuery { + /// Validate one bounded accessibility lookup request. + /// + /// Explicit empty values fail closed rather than being treated as absent. Role and name limits + /// are measured in UTF-8 bytes so later serialization cannot exceed the reviewed local budget + /// through multi-byte text. Roles are exact WAI-ARIA tokens, so whitespace, control, and + /// Unicode format characters fail closed instead of becoming fallback-role lists. Accessible + /// names may contain ordinary spaces but not controls, Unicode format characters, or + /// whitespace-only values. At least one selector value and one result slot are required. + pub fn new( + role: Option<&str>, + name: Option<&str>, + max_node_count: u16, + ) -> Result { + if role.is_some_and(str::is_empty) { + return Err(WebDriverBiDiAccessibilityQueryError::EmptyRole); + } + if role.is_some_and(|value| crate::contains_disallowed_protocol_text(value, false)) { + return Err(WebDriverBiDiAccessibilityQueryError::InvalidRole); + } + if role.is_some_and(|value| value.len() > MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES) { + return Err(WebDriverBiDiAccessibilityQueryError::RoleTooLong); + } + if name.is_some_and(str::is_empty) { + return Err(WebDriverBiDiAccessibilityQueryError::EmptyName); + } + if name.is_some_and(|value| { + crate::contains_disallowed_protocol_text(value, true) + || value.chars().all(char::is_whitespace) + }) { + return Err(WebDriverBiDiAccessibilityQueryError::InvalidName); + } + if name.is_some_and(|value| value.len() > MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES) { + return Err(WebDriverBiDiAccessibilityQueryError::NameTooLong); + } + if role.is_none() && name.is_none() { + return Err(WebDriverBiDiAccessibilityQueryError::MissingLocatorValue); + } + if max_node_count == 0 || max_node_count > MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT { + return Err(WebDriverBiDiAccessibilityQueryError::InvalidNodeCount); + } + + Ok(Self { + role: role.map(str::to_owned), + name: name.map(str::to_owned), + max_node_count, + }) + } + + /// Return the exact upstream method associated with this query contract. + #[must_use] + pub const fn method(&self) -> &'static str { + WEBDRIVER_BIDI_LOCATE_NODES_METHOD + } + + /// Return the exact WebDriver BiDi locator type represented by this value. + #[must_use] + pub const fn locator_type(&self) -> &'static str { + "accessibility" + } + + /// Return the fixed maximum DOM serialization depth for returned remote nodes. + #[must_use] + pub const fn serialization_max_dom_depth(&self) -> u16 { + WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH + } + + /// Return the fixed maximum object serialization depth for returned remote nodes. + #[must_use] + pub const fn serialization_max_object_depth(&self) -> u16 { + WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH + } + + /// Return the fixed shadow-tree serialization mode for returned remote nodes. + #[must_use] + pub const fn serialization_include_shadow_tree(&self) -> &'static str { + WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE + } + + /// Return the exact optional accessibility role requested by the caller. + #[must_use] + pub fn role(&self) -> Option<&str> { + self.role.as_deref() + } + + /// Return the exact optional accessible name requested by the caller. + #[must_use] + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// Return the finite maximum number of nodes requested from the adapter. + #[must_use] + pub const fn max_node_count(&self) -> u16 { + self.max_node_count + } + + /// Revalidate an untrusted `locateNodes` result count against this exact request budget. + /// + /// A conforming browser is expected to honor `maxNodeCount`, but an adapter boundary must not + /// treat that expectation as resource authority. Zero through the requested maximum are valid; + /// any larger returned array fails closed before later node normalization or retention. + pub fn validate_result_count( + &self, + returned_node_count: usize, + ) -> Result<(), WebDriverBiDiAccessibilityQueryError> { + if returned_node_count > usize::from(self.max_node_count) { + return Err(WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded); + } + Ok(()) + } + + /// Admit one untrusted `locateNodes` result against the exact current document authority. + /// + /// The caller must transfer a non-cloneable [`ValidatedBrowserProtocolUse`] whose protocol + /// family is exactly [`BrowserProtocolKind::WebDriverBiDi`] and whose capability is exactly + /// [`BrowserProtocolCapability::SemanticObservation`]. A CDP proof or a Navigation/TypedInput + /// proof fails closed before the registry is consulted, so another protocol surface cannot + /// mint WebDriver BiDi observation handles. The proof is consumed by ownership and cannot be + /// reused for a later bind. + /// + /// The registry then proves that `target` still names the current session, browsing context, + /// canonical origin, and document epoch. Only then is the returned item count checked against + /// this query's budget. Each item must be an exact `node` remote value with a usable shared + /// identifier. The complete admitted batch is then translated atomically into + /// [`ObservedNodeHandle`] values bound to that same current authority: if any registry binding + /// fails, no partial node authority from this call is retained. + /// + /// This method performs no browser I/O, does not authenticate Chromium, and does not grant + /// policy, destination, or typed-input authority. A later action must still revalidate the + /// returned handles immediately before use. + pub fn bind_current_nodes( + &self, + validated: ValidatedBrowserProtocolUse, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + items: &[(&str, Option<&str>)], + ) -> Result, WebDriverBiDiLocateNodesAdmissionError> { + if validated.kind() != BrowserProtocolKind::WebDriverBiDi { + return Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind(validated.kind()), + ); + } + if validated.capability() != BrowserProtocolCapability::SemanticObservation { + return Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + validated.capability(), + ), + ); + } + let _consumed_query_nodes_proof = validated; + let context_origin = target.context_origin(); + let context = context_origin.context(); + let current_epoch = authority_registry + .require_context_origin( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + if current_epoch != target.expected_epoch() { + return Err( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected: target.expected_epoch(), + current: current_epoch, + }, + ); + } + self.validate_result_count(items.len()) + .map_err(WebDriverBiDiLocateNodesAdmissionError::Query)?; + + let mut references = Vec::new(); + for (remote_type, shared_id) in items { + references.push( + WebDriverBiDiRemoteNodeReference::new(remote_type, *shared_id) + .map_err(WebDriverBiDiLocateNodesAdmissionError::RemoteNode)?, + ); + } + + let shared_ids = references + .iter() + .map(WebDriverBiDiRemoteNodeReference::shared_id) + .collect::>(); + authority_registry + .bind_nodes( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + &shared_ids, + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority) + } +} + +/// Fail-closed errors for QueryNodes admission that requires SemanticObservation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiQueryNodesAdmissionError { + /// Protocol metadata or the QueryNodes capability failed before node admission. + ProtocolDispatch(BrowserContextProtocolDispatchError), + /// The untrusted `locateNodes` result failed current-authority admission. + LocateNodes(WebDriverBiDiLocateNodesAdmissionError), +} + +impl Display for WebDriverBiDiQueryNodesAdmissionError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::ProtocolDispatch(error) => { + write!( + formatter, + "QueryNodes protocol dispatch rejected locateNodes admission: {error}" + ) + } + Self::LocateNodes(error) => { + write!( + formatter, + "QueryNodes current-authority admission rejected locateNodes result: {error}" + ) + } + } + } +} + +impl Error for WebDriverBiDiQueryNodesAdmissionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::ProtocolDispatch(error) => Some(error), + Self::LocateNodes(error) => Some(error), + } + } +} + +/// Fail-closed errors for admitting an untrusted `locateNodes` result into current node authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesAdmissionError { + /// The untrusted result violated this query's reviewed locator or result-count contract. + Query(WebDriverBiDiAccessibilityQueryError), + /// One result item was not an admissible node remote value. + RemoteNode(WebDriverBiDiRemoteNodeReferenceError), + /// The observed document epoch no longer matches the registry's current document. + DocumentEpochMismatch { + /// The document epoch that produced the observation being bound. + expected: DocumentEpoch, + /// The document epoch currently active in the registry. + current: DocumentEpoch, + }, + /// The supplied browser session, context, or origin is not current in the registry. + BrowserAuthority(BrowserRegistryError), + /// The consumed protocol-use proof came from a different browser protocol family. + UnsupportedProtocolKind(BrowserProtocolKind), + /// The consumed protocol-use proof was not SemanticObservation. + UnsupportedCapability(BrowserProtocolCapability), +} + +impl Display for WebDriverBiDiLocateNodesAdmissionError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Query(error) => { + write!( + formatter, + "accessibility query rejected locateNodes admission: {error}" + ) + } + Self::RemoteNode(error) => { + write!( + formatter, + "remote node reference rejected locateNodes admission: {error}" + ) + } + Self::DocumentEpochMismatch { expected, current } => write!( + formatter, + "browser document epoch {} no longer matches observed epoch {}", + current.value(), + expected.value() + ), + Self::BrowserAuthority(error) => { + write!( + formatter, + "browser authority denied locateNodes admission: {error}" + ) + } + Self::UnsupportedProtocolKind(kind) => write!( + formatter, + "locateNodes admission requires a WebDriverBiDi protocol-use proof, not {kind:?}" + ), + Self::UnsupportedCapability(capability) => { + let name = match capability { + BrowserProtocolCapability::Navigation => "Navigation", + BrowserProtocolCapability::SemanticObservation => "SemanticObservation", + BrowserProtocolCapability::TypedInput => "TypedInput", + BrowserProtocolCapability::NetworkObservation => "NetworkObservation", + }; + write!( + formatter, + "locateNodes admission requires a SemanticObservation protocol-use proof, not {name}" + ) + } + } + } +} + +impl Error for WebDriverBiDiLocateNodesAdmissionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Query(error) => Some(error), + Self::RemoteNode(error) => Some(error), + Self::DocumentEpochMismatch { .. } => None, + Self::BrowserAuthority(error) => Some(error), + Self::UnsupportedProtocolKind(_) | Self::UnsupportedCapability(_) => None, + } + } +} + +/// Exact WebDriver BiDi remote-value type admitted as a later node handle. +pub const WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE: &str = "node"; + +/// Fail-closed validation errors for one untrusted WebDriver BiDi node remote value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiRemoteNodeReferenceError { + /// The remote value type was not the exact `node` type. + UnexpectedRemoteType, + /// The remote value omitted `sharedId`. + MissingSharedId, + /// The shared identifier was empty, contained control, whitespace, or Unicode format text, or exceeded the local budget. + InvalidSharedId, +} + +impl Display for WebDriverBiDiRemoteNodeReferenceError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::UnexpectedRemoteType => { + "remote node reference type must be the exact node remote value" + } + Self::MissingSharedId => "remote node reference requires a shared id", + Self::InvalidSharedId => { + "remote node reference shared id is empty, contains control, whitespace, or Unicode format characters, or exceeds the local byte budget" + } + }; + formatter.write_str(message) + } +} + +impl Error for WebDriverBiDiRemoteNodeReferenceError {} + +/// Bounded admission of one untrusted WebDriver BiDi `script.NodeRemoteValue`. +/// +/// The 1 June 2026 WebDriver BiDi Working Draft returns `script.NodeRemoteValue` items from +/// `browsingContext.locateNodes`. Those values have a required `type` of `node` and an optional +/// `sharedId`. OriginWeave admits a result item only when the type is exactly `node` and a +/// non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context +/// identifiers and contains no control, whitespace, or Unicode format characters. +/// +/// Requiring `sharedId` is a local fail-closed policy: the Working Draft permits omitting it, but a +/// later typed-input adapter cannot refer to the same node across realms without that shared +/// identity. Construction grants no session, context, origin, document-epoch, semantic-node, +/// policy, or network authority and performs no browser I/O. The admitted value remains an +/// untrusted transport handle until a separately reviewed authority boundary binds it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebDriverBiDiRemoteNodeReference { + shared_id: String, +} + +impl WebDriverBiDiRemoteNodeReference { + /// Admit one untrusted locateNodes remote value as a later node handle. + /// + /// The remote type is checked first so a non-node value cannot be retained even when it carries + /// a well-formed shared identifier. A missing shared identifier is distinct from an empty or + /// over-budget identifier so callers can distinguish protocol omission from local + /// resource-budget rejection. + pub fn new( + remote_type: &str, + shared_id: Option<&str>, + ) -> Result { + if remote_type != WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE { + return Err(WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType); + } + let Some(shared_id) = shared_id else { + return Err(WebDriverBiDiRemoteNodeReferenceError::MissingSharedId); + }; + if shared_id.is_empty() + || shared_id.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + || crate::contains_disallowed_protocol_text(shared_id, false) + { + return Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId); + } + Ok(Self { + shared_id: shared_id.to_owned(), + }) + } + + /// Return the exact admitted WebDriver BiDi remote-value type. + #[must_use] + pub const fn remote_type(&self) -> &'static str { + WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE + } + + /// Return the exact shared node identifier admitted from the remote value. + #[must_use] + pub fn shared_id(&self) -> &str { + &self.shared_id + } +} + +/// One typed buyer-visible browser operation whose transport prerequisite is derived internally. +/// +/// This value carries operation semantics only. It grants no browser session, context, origin, +/// semantic-node, policy, approval, secret, network, or adapter authority and does not perform +/// browser I/O. The vocabulary intentionally mirrors the bounded first Chromium vertical slice so +/// callers cannot hide materially different actions behind a coarse transport capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserProtocolOperation { + /// Navigate one controlled browser context. + Navigate, + /// Query bounded semantic nodes from the current document. + QueryNodes, + /// Dispatch one policy-authorized click to a separately validated semantic node. + ClickNode, + /// Dispatch policy-authorized text input to a separately validated semantic node. + TypeText, + /// Observe bounded semantic state until a separately specified condition is satisfied. + WaitForState, + /// Produce bounded browser-network evidence. + ObserveNetwork, +} + +impl BrowserProtocolOperation { + /// Return the exact adapter capability required for this typed operation. + /// + /// This mapping is a transport prerequisite only. A matching capability does not authorize the + /// operation itself: node freshness, policy, approval, destination, and post-condition checks + /// remain independent authority boundaries. + #[must_use] + pub const fn required_capability(self) -> BrowserProtocolCapability { + match self { + Self::Navigate => BrowserProtocolCapability::Navigation, + Self::QueryNodes | Self::WaitForState => BrowserProtocolCapability::SemanticObservation, + Self::ClickNode | Self::TypeText => BrowserProtocolCapability::TypedInput, + Self::ObserveNetwork => BrowserProtocolCapability::NetworkObservation, + } + } +} + +impl BrowserProtocolAdapterDescriptor { + /// Revalidate exact browser authority and derive adapter capability from one typed operation. + /// + /// The existing context/origin/document-epoch boundary runs first, followed by exact runtime + /// protocol metadata and the capability derived from `operation`. The callback can run only + /// after all prerequisites pass and receives the same typed operation together with the + /// non-cloneable protocol-use proof and freshly revalidated document epoch. + /// + /// This method does not authenticate Chromium or the adapter, grant policy approval, validate + /// semantic-node state, authorize destination/network activity, perform browser I/O, or prove + /// an action post-condition. The operation value itself grants no authority. + pub fn dispatch_operation_if_context_origin_epoch_current( + &self, + authority_registry: &BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + operation: BrowserProtocolOperation, + dispatch: F, + ) -> Result + where + F: FnOnce(ValidatedBrowserProtocolUse, BrowserProtocolOperation, DocumentEpoch) -> R, + { + self.dispatch_if_context_origin_epoch_current( + authority_registry, + target, + required_originweave_protocol_version, + runtime_metadata, + operation.required_capability(), + |validated, epoch| dispatch(validated, operation, epoch), + ) + } + + /// Admit one untrusted `locateNodes` result only after QueryNodes protocol proof. + /// + /// The same-call boundary first obtains a non-cloneable protocol-use proof for + /// [`BrowserProtocolOperation::QueryNodes`], which derives + /// [`BrowserProtocolCapability::SemanticObservation`]. That proof is transferred by + /// ownership into [`WebDriverBiDiAccessibilityQuery::bind_current_nodes`], which requires + /// the exact [`BrowserProtocolKind::WebDriverBiDi`] family and refuses any other capability + /// before translating admitted `sharedId` values into [`ObservedNodeHandle`] values on the + /// exact current session, browsing context, canonical origin, and document epoch. + /// + /// This method performs no browser I/O, does not authenticate Chromium, and does not grant + /// policy, destination, or typed-input authority. A later action must still revalidate the + /// returned handles immediately before use. + pub fn admit_query_nodes( + &self, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + required_originweave_protocol_version: OriginWeaveProtocolVersion, + runtime_metadata: BrowserProtocolRuntimeMetadata<'_>, + query: &WebDriverBiDiAccessibilityQuery, + items: &[(&str, Option<&str>)], + ) -> Result, WebDriverBiDiQueryNodesAdmissionError> { + let validated = self + .dispatch_operation_if_context_origin_epoch_current( + authority_registry, + target, + required_originweave_protocol_version, + runtime_metadata, + BrowserProtocolOperation::QueryNodes, + |validated, _operation, _epoch| validated, + ) + .map_err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch)?; + query + .bind_current_nodes(validated, authority_registry, target, items) + .map_err(WebDriverBiDiQueryNodesAdmissionError::LocateNodes) + } +} diff --git a/crates/originweave-core/src/browser_registry.rs b/crates/originweave-core/src/browser_registry.rs new file mode 100644 index 000000000..26a05249f --- /dev/null +++ b/crates/originweave-core/src/browser_registry.rs @@ -0,0 +1,864 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use crate::{BrowserSessionId, BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin}; + +/// Maximum UTF-8 byte length of an opaque browser-protocol identifier retained by the registry. +pub const MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES: usize = 512; + +/// Invisible and bidirectional Unicode format characters rejected in protocol text. +/// +/// These code points are Default_Ignorable or bidirectional format controls. They can hide or +/// reorder locator and identifier text without being `char::is_control` or `char::is_whitespace`. +/// The reviewed set is a local fail-closed policy for OriginWeave protocol admission, not a claim +/// that every Unicode format character is forbidden by WebDriver BiDi or WAI-ARIA. +pub const UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS: &[char] = &[ + '\u{00AD}', '\u{061C}', '\u{180E}', '\u{200B}', '\u{200C}', '\u{200D}', '\u{200E}', '\u{200F}', + '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2060}', '\u{2061}', '\u{2062}', + '\u{2063}', '\u{2064}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', '\u{206A}', '\u{206B}', + '\u{206C}', '\u{206D}', '\u{206E}', '\u{206F}', '\u{FEFF}', +]; + +/// Return whether protocol text contains a control, whitespace, or reviewed format character. +/// +/// When `allow_ordinary_space` is true, U+0020 may appear so accessible names can keep ordinary +/// spaces. Every other whitespace character, every control, and every reviewed format character +/// still fail closed. +pub(crate) fn contains_disallowed_protocol_text(value: &str, allow_ordinary_space: bool) -> bool { + value.chars().any(|character| { + if allow_ordinary_space && character == ' ' { + return false; + } + character.is_control() + || character.is_whitespace() + || UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS.contains(&character) + }) +} + +/// Default maximum number of authority identifiers allocated per registry namespace. +const DEFAULT_MAX_BROWSER_AUTHORITY_IDENTIFIERS: u64 = 1_000_000; + +/// A bounded in-memory mapping from untrusted adapter identifiers to OriginWeave authority values. +/// +/// External WebDriver BiDi, CDP, renderer, frame, and DOM identifiers are retained only as +/// private lookup keys. Callers receive OriginWeave-owned numeric identities whose meaning is +/// scoped to this registry instance. Node identities are additionally scoped to one browsing +/// context, document epoch, and canonical origin. +pub struct BrowserAuthorityRegistry { + session_by_external: BTreeMap, + known_sessions: BTreeSet, + context_by_external: BTreeMap<(BrowserSessionId, String), BrowsingContextId>, + context_session: BTreeMap, + context_epoch: BTreeMap, + context_origin: BTreeMap, + node_by_external: BTreeMap<(BrowsingContextId, DocumentEpoch, String), u64>, + maximum_identifier: u64, + next_session_id: u64, + next_context_id: u64, + next_node_id: u64, +} + +impl BrowserAuthorityRegistry { + /// Create an empty registry with the reviewed default per-namespace identifier capacity. + #[must_use] + pub fn new() -> Self { + Self::with_identifier_limit(DEFAULT_MAX_BROWSER_AUTHORITY_IDENTIFIERS) + } + + /// Create an empty registry with a caller-selected per-namespace identifier capacity. + /// + /// Session, browsing-context, and node identifiers each have an independent monotonic + /// namespace capped at `maximum_identifier`. A zero limit intentionally rejects every new + /// allocation. Values above `u64::MAX - 1` are clamped so incrementing the next identifier + /// never wraps to zero. + #[must_use] + pub fn with_identifier_limit(maximum_identifier: u64) -> Self { + let maximum_identifier = maximum_identifier.min(u64::MAX - 1); + Self { + session_by_external: BTreeMap::new(), + known_sessions: BTreeSet::new(), + context_by_external: BTreeMap::new(), + context_session: BTreeMap::new(), + context_epoch: BTreeMap::new(), + context_origin: BTreeMap::new(), + node_by_external: BTreeMap::new(), + maximum_identifier, + next_session_id: 1, + next_context_id: 1, + next_node_id: 1, + } + } + + /// Register one opaque external browser-session identifier. + /// + /// Re-registering the same identifier in this registry returns the same OriginWeave session. + pub fn register_session( + &mut self, + external_identifier: &str, + ) -> Result { + validate_external_identifier(external_identifier)?; + if let Some(existing) = self.session_by_external.get(external_identifier) { + return Ok(*existing); + } + let identifier = take_identifier(&mut self.next_session_id, self.maximum_identifier)?; + browser_session_id(identifier).inspect(|&session| { + self.session_by_external + .insert(external_identifier.to_owned(), session); + self.known_sessions.insert(session); + }) + } + + /// Register one opaque external browsing-context identifier inside a known browser session. + /// + /// A newly registered context starts at document epoch one. The same external context text in + /// another browser session receives a different OriginWeave context identity. + pub fn register_context( + &mut self, + browser_session: BrowserSessionId, + external_identifier: &str, + ) -> Result { + validate_external_identifier(external_identifier)?; + if !self.known_sessions.contains(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let key = (browser_session, external_identifier.to_owned()); + if let Some(existing) = self.context_by_external.get(&key) { + return Ok(*existing); + } + let identifier = take_identifier(&mut self.next_context_id, self.maximum_identifier)?; + browsing_context_id(identifier).and_then(|context| { + document_epoch(1).map(|initial_epoch| { + self.context_by_external.insert(key, context); + self.context_session.insert(context, browser_session); + self.context_epoch.insert(context, initial_epoch); + context + }) + }) + } + + /// Retire one browsing context and all registry-local authority derived from it. + /// + /// Retirement removes external lookup state, the current document epoch and origin, and every + /// node binding owned by the context. Monotonic context and node identifiers are never reused. + /// This revokes only OriginWeave registry-local authority; it does not prove that an external + /// browser context or process has terminated. + pub fn remove_context( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result<(), BrowserRegistryError> { + if self.context_session.remove(&browsing_context).is_none() { + return Err(BrowserRegistryError::UnknownBrowsingContext); + } + self.context_by_external + .retain(|_key, context| *context != browsing_context); + self.context_epoch.remove(&browsing_context); + self.context_origin.remove(&browsing_context); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + Ok(()) + } + + /// Retire one browser session and every registered context and node binding beneath it. + /// + /// Retirement removes only registry-local authority and external lookup state. Session, + /// context, and node identifiers remain strictly monotonic so a later registration of the same + /// opaque browser identifier cannot revive stale authority. External process termination is a + /// separate adapter responsibility. + pub fn remove_session( + &mut self, + browser_session: BrowserSessionId, + ) -> Result<(), BrowserRegistryError> { + if !self.known_sessions.remove(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + self.session_by_external + .retain(|_external, session| *session != browser_session); + self.context_by_external + .retain(|(session, _external), _context| *session != browser_session); + self.context_session + .retain(|_context, session| *session != browser_session); + + let live_contexts = &self.context_session; + self.context_epoch + .retain(|context, _epoch| live_contexts.contains_key(context)); + self.context_origin + .retain(|context, _origin| live_contexts.contains_key(context)); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| live_contexts.contains_key(context)); + Ok(()) + } + + /// Return the currently active document epoch for a known browsing context. + pub fn current_epoch( + &self, + browsing_context: BrowsingContextId, + ) -> Result { + self.context_epoch + .get(&browsing_context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext) + } + + /// Return the current document epoch only when the supplied session owns the context. + /// + /// This is an immediate-use registry check for trusted browser adapters. It proves only that + /// the OriginWeave session/context pair is currently registered together and returns the + /// registry's current document epoch. It does not authenticate a browser process, authorize an + /// origin or action, or make the returned epoch a reusable browser capability. + pub fn current_context_epoch( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + ) -> Result { + if !self.known_sessions.contains(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let expected_session = self + .context_session + .get(&browsing_context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + if expected_session != browser_session { + return Err(BrowserRegistryError::ContextSessionMismatch { + expected: expected_session, + actual: browser_session, + }); + } + self.current_epoch(browsing_context) + } + + /// Require an opaque external browsing-context identifier to name this exact context. + /// + /// This read-only check binds transport-level context text back to the already-registered + /// OriginWeave session/context pair. It never registers a new external context as a side effect, + /// so an untrusted result cannot create authority merely by presenting a different identifier. + pub(crate) fn require_context_external_identifier( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + external_identifier: &str, + ) -> Result<(), BrowserRegistryError> { + validate_external_identifier(external_identifier)?; + self.current_context_epoch(browser_session, browsing_context)?; + let key = (browser_session, external_identifier.to_owned()); + if self.context_by_external.get(&key).copied() != Some(browsing_context) { + return Err(BrowserRegistryError::ContextExternalIdentifierMismatch); + } + Ok(()) + } + + /// Bind the canonical origin observed for the exact current browser document. + /// + /// This boundary lets a trusted browser adapter establish current document-origin state before + /// semantic-node discovery begins. The supplied session must own the context. Rebinding the + /// same canonical origin in the same document epoch is idempotent, while a different origin + /// fails closed until [`Self::advance_document`] rotates the document epoch and clears the old + /// binding. The returned epoch is descriptive immediate-use state, not reusable capability. + /// + /// This method does not authenticate the adapter, derive an origin from Chromium, authorize a + /// destination or action, or prove that any browser I/O occurred. + pub fn bind_context_origin( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + ) -> Result { + let epoch = self.current_context_epoch(browser_session, browsing_context)?; + match self.context_origin.get(&browsing_context) { + Some(expected_origin) if expected_origin != origin => { + return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); + } + Some(_expected_origin) => {} + None => { + self.context_origin.insert(browsing_context, origin.clone()); + } + } + Ok(epoch) + } + + /// Revalidate the canonical origin bound to the exact current browser document. + /// + /// This read-only immediate-use boundary lets a trusted browser adapter prove that the exact + /// OriginWeave session/context still has the expected canonical origin in its current document + /// epoch. It fails closed when the current document has no origin binding, including directly + /// after [`Self::advance_document`], and rejects a different origin without mutating registry + /// state. The returned epoch is descriptive current state, not a reusable capability. + /// + /// This method does not authenticate the adapter or browser process, derive the current origin + /// from Chromium, authorize a destination or action, perform browser I/O, or attest that the + /// caller-supplied origin came from the running browser. + pub fn require_context_origin( + &self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + ) -> Result { + let epoch = self.current_context_epoch(browser_session, browsing_context)?; + let expected_origin = self + .context_origin + .get(&browsing_context) + .ok_or(BrowserRegistryError::ContextOriginNotBound)?; + if expected_origin != origin { + return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); + } + Ok(epoch) + } + + /// Advance a browsing context to the next document epoch and invalidate old node bindings. + /// + /// Call this whenever navigation or document replacement invalidates actionable node identity. + pub fn advance_document( + &mut self, + browsing_context: BrowsingContextId, + ) -> Result { + let current = self + .context_epoch + .get(&browsing_context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + let next_value = current + .value() + .checked_add(1) + .ok_or(BrowserRegistryError::DocumentEpochExhausted)?; + document_epoch(next_value).inspect(|&next| { + self.context_epoch.insert(browsing_context, next); + self.context_origin.remove(&browsing_context); + self.node_by_external + .retain(|(context, _epoch, _external), _node_id| *context != browsing_context); + }) + } + + /// Bind one opaque adapter-local node identifier to the exact current browser authority. + /// + /// Rebinding the same adapter node inside the same document returns a stable OriginWeave node + /// identifier. A document advance discards that mapping, so adapter node-number reuse cannot + /// revive stale authority. An origin change without a document advance fails closed. + pub fn bind_node( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifier: &str, + ) -> Result { + validate_external_identifier(external_identifier)?; + if !self.known_sessions.contains(&browser_session) { + return Err(BrowserRegistryError::UnknownBrowserSession); + } + let expected_session = self + .context_session + .get(&browsing_context) + .copied() + .ok_or(BrowserRegistryError::UnknownBrowsingContext)?; + if expected_session != browser_session { + return Err(BrowserRegistryError::ContextSessionMismatch { + expected: expected_session, + actual: browser_session, + }); + } + match self.context_origin.get(&browsing_context) { + Some(expected_origin) if expected_origin != origin => { + return Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance); + } + Some(_expected_origin) => {} + None => { + self.context_origin.insert(browsing_context, origin.clone()); + } + } + self.current_epoch(browsing_context).and_then(|epoch| { + let key = (browsing_context, epoch, external_identifier.to_owned()); + let node_id = if let Some(existing) = self.node_by_external.get(&key) { + *existing + } else { + let allocated = take_identifier(&mut self.next_node_id, self.maximum_identifier)?; + self.node_by_external.insert(key, allocated); + allocated + }; + observed_node_handle(browser_session, browsing_context, origin, epoch, node_id) + }) + } + + /// Bind a batch of node identifiers transactionally to the exact current browser authority. + /// + /// Successful bindings are retained only when every identifier in the batch succeeds. If a + /// later identifier fails validation, authority checks, identifier allocation, or handle + /// construction, node mappings allocated by this batch are removed, the next node identifier + /// is restored, and an origin first established by this batch is removed before the error is + /// returned. Handles created earlier in the failed batch never escape this method, so restoring + /// the local allocation cursor cannot revive externally observable stale authority. + pub(crate) fn bind_nodes( + &mut self, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifiers: &[&str], + ) -> Result, BrowserRegistryError> { + let starting_next_node_id = self.next_node_id; + let had_origin = self.context_origin.contains_key(&browsing_context); + let mut handles = Vec::with_capacity(external_identifiers.len()); + for external_identifier in external_identifiers { + match self.bind_node( + browser_session, + browsing_context, + origin, + external_identifier, + ) { + Ok(handle) => handles.push(handle), + Err(error) => { + self.node_by_external + .retain(|_key, node_id| *node_id < starting_next_node_id); + self.next_node_id = starting_next_node_id; + if !had_origin { + self.context_origin.remove(&browsing_context); + } + return Err(error); + } + } + } + Ok(handles) + } +} + +impl Default for BrowserAuthorityRegistry { + fn default() -> Self { + Self::new() + } +} + +/// A fail-closed error produced while translating external browser identifiers into local authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserRegistryError { + /// An external identifier was empty, contained control, whitespace, or Unicode format text, or exceeded the reviewed byte bound. + InvalidExternalIdentifier, + /// The supplied OriginWeave browser session is not registered in this registry. + UnknownBrowserSession, + /// The supplied OriginWeave browsing context is not registered in this registry. + UnknownBrowsingContext, + /// The browsing context belongs to another browser session. + ContextSessionMismatch { + /// Session that owns the registered context. + expected: BrowserSessionId, + /// Session supplied by the current caller. + actual: BrowserSessionId, + }, + /// The transport-level browsing-context identifier does not name the supplied registered context. + ContextExternalIdentifierMismatch, + /// The current document has no canonical origin bound to the browsing context. + ContextOriginNotBound, + /// The context origin changed without first rotating the document epoch. + OriginChangedWithoutDocumentAdvance, + /// The registry exhausted one of its monotonic internal identifier spaces. + IdentifierSpaceExhausted, + /// A document epoch reached the maximum representable value. + DocumentEpochExhausted, + /// An internal nonzero authority invariant was violated. + InternalAuthorityInvariant, +} + +impl fmt::Display for BrowserRegistryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidExternalIdentifier => formatter.write_str( + "external browser identifier must contain 1 to 512 UTF-8 bytes without control, whitespace, or Unicode format characters", + ), + Self::UnknownBrowserSession => { + formatter.write_str("browser session is not registered in this authority registry") + } + Self::UnknownBrowsingContext => { + formatter.write_str("browsing context is not registered in this authority registry") + } + Self::ContextSessionMismatch { expected, actual } => write!( + formatter, + "browsing context belongs to session {}, not session {}", + expected.value(), + actual.value() + ), + Self::ContextExternalIdentifierMismatch => formatter.write_str( + "browsing context external identifier does not match the registered context", + ), + Self::ContextOriginNotBound => formatter.write_str( + "browsing context has no canonical origin bound for the current document", + ), + Self::OriginChangedWithoutDocumentAdvance => formatter + .write_str("browsing context origin changed without advancing the document epoch"), + Self::IdentifierSpaceExhausted => { + formatter.write_str("browser authority identifier space is exhausted") + } + Self::DocumentEpochExhausted => { + formatter.write_str("browser document epoch space is exhausted") + } + Self::InternalAuthorityInvariant => { + formatter.write_str("browser authority registry violated a nonzero invariant") + } + } + } +} + +impl std::error::Error for BrowserRegistryError {} + +fn validate_external_identifier(identifier: &str) -> Result<(), BrowserRegistryError> { + if identifier.is_empty() + || identifier.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + || contains_disallowed_protocol_text(identifier, false) + { + return Err(BrowserRegistryError::InvalidExternalIdentifier); + } + Ok(()) +} + +fn take_identifier(next: &mut u64, maximum_identifier: u64) -> Result { + if *next > maximum_identifier { + return Err(BrowserRegistryError::IdentifierSpaceExhausted); + } + let identifier = *next; + *next = identifier + 1; + Ok(identifier) +} + +fn browser_session_id(value: u64) -> Result { + BrowserSessionId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +fn browsing_context_id(value: u64) -> Result { + BrowsingContextId::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +fn document_epoch(value: u64) -> Result { + DocumentEpoch::new(value).map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +fn observed_node_handle( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + document_epoch: DocumentEpoch, + node_id: u64, +) -> Result { + ObservedNodeHandle::new( + browser_session, + browsing_context, + origin.clone(), + document_epoch, + node_id, + ) + .map_err(|_error| BrowserRegistryError::InternalAuthorityInvariant) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn values(result: Result) -> Vec { + result.into_iter().collect() + } + + #[test] + fn helper_invariants_fail_closed() { + assert_eq!( + browser_session_id(0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + assert_eq!( + browsing_context_id(0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + assert_eq!( + document_epoch(0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + + let sessions = values(BrowserSessionId::new(1)); + let contexts = values(BrowsingContextId::new(1)); + let epochs = values(DocumentEpoch::new(1)); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(sessions.len(), 1); + assert_eq!(contexts.len(), 1); + assert_eq!(epochs.len(), 1); + assert_eq!(origins.len(), 1); + assert_eq!( + observed_node_handle(sessions[0], contexts[0], &origins[0], epochs[0], 0), + Err(BrowserRegistryError::InternalAuthorityInvariant) + ); + } + + #[test] + fn monotonic_identifier_exhaustion_is_fail_closed() { + let mut next = 1; + assert_eq!(take_identifier(&mut next, 1), Ok(1)); + assert_eq!(next, 2); + assert_eq!( + take_identifier(&mut next, 1), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + } + + #[test] + fn registry_reports_all_resource_and_authority_failures() { + let known_sessions = values(BrowserSessionId::new(1)); + let unknown_contexts = values(BrowsingContextId::new(1)); + let initial_epochs = values(DocumentEpoch::new(1)); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(known_sessions.len(), 1); + assert_eq!(unknown_contexts.len(), 1); + assert_eq!(initial_epochs.len(), 1); + assert_eq!(origins.len(), 1); + let known_session = known_sessions[0]; + let unknown_context = unknown_contexts[0]; + let initial_epoch = initial_epochs[0]; + let origin = &origins[0]; + + let mut limited_registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let limited_sessions = values(limited_registry.register_session("session-one")); + assert_eq!(limited_sessions.len(), 1); + let limited_session = limited_sessions[0]; + assert_eq!( + limited_registry.register_session("session-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + let limited_contexts = + values(limited_registry.register_context(limited_session, "context-one")); + assert_eq!(limited_contexts.len(), 1); + let limited_context = limited_contexts[0]; + assert_eq!( + limited_registry.register_context(limited_session, "context-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + assert!( + limited_registry + .bind_node(limited_session, limited_context, origin, "node-one") + .is_ok() + ); + assert_eq!( + limited_registry.bind_node(limited_session, limited_context, origin, "node-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let mut registry = BrowserAuthorityRegistry::default(); + assert_eq!( + registry.current_epoch(unknown_context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.advance_document(unknown_context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.bind_node(known_session, unknown_context, origin, "node"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let sessions = values(registry.register_session("session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "context-a")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + assert_eq!( + registry.require_context_external_identifier(session, context, "context-a"), + Ok(()) + ); + assert_eq!( + registry.require_context_external_identifier(session, context, "context-b"), + Err(BrowserRegistryError::ContextExternalIdentifierMismatch) + ); + assert_eq!( + registry.require_context_external_identifier(session, context, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let maximum_epochs = values(DocumentEpoch::new(u64::MAX)); + assert_eq!(maximum_epochs.len(), 1); + registry.context_epoch.insert(context, maximum_epochs[0]); + assert_eq!( + registry.advance_document(context), + Err(BrowserRegistryError::DocumentEpochExhausted) + ); + registry.context_epoch.insert(context, initial_epoch); + + let unknown_sessions = values(BrowserSessionId::new(999)); + let unknown_contexts = values(BrowsingContextId::new(999)); + assert_eq!(unknown_sessions.len(), 1); + assert_eq!(unknown_contexts.len(), 1); + assert_eq!( + registry.require_context_external_identifier(unknown_sessions[0], context, "context-a"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + assert_eq!( + registry.require_context_external_identifier(session, unknown_contexts[0], "context-a"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.bind_node(unknown_sessions[0], context, origin, "node"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + assert_eq!( + registry.bind_node(session, unknown_contexts[0], origin, "node"), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + } + + #[test] + fn batched_node_binding_rolls_back_partial_authority() { + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(2); + let sessions = values(registry.register_session("session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + let existing = values(registry.bind_node(session, context, origin, "existing")); + assert_eq!(existing.len(), 1); + assert_eq!(existing[0].node_id(), 1); + assert_eq!( + registry.bind_nodes(session, context, origin, &["existing", "fresh", "overflow"]), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + assert_eq!(registry.node_by_external.len(), 1); + assert_eq!(registry.next_node_id, 2); + assert!(registry.context_origin.contains_key(&context)); + let recovery = values(registry.bind_node(session, context, origin, "recovery")); + assert_eq!(recovery.len(), 1); + assert_eq!(recovery[0].node_id(), 2); + + let mut unbound_registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let sessions = values(unbound_registry.register_session("unbound-session")); + assert_eq!(sessions.len(), 1); + let unbound_session = sessions[0]; + let contexts = + values(unbound_registry.register_context(unbound_session, "unbound-context")); + assert_eq!(contexts.len(), 1); + let unbound_context = contexts[0]; + assert!( + !unbound_registry + .context_origin + .contains_key(&unbound_context) + ); + assert_eq!( + unbound_registry.bind_nodes( + unbound_session, + unbound_context, + origin, + &["first", "overflow"], + ), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + assert!( + !unbound_registry + .context_origin + .contains_key(&unbound_context) + ); + assert!(unbound_registry.node_by_external.is_empty()); + assert_eq!(unbound_registry.next_node_id, 1); + } + + #[test] + fn origin_rotation_and_node_cleanup_are_explicit() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + let contexts = values(registry.register_context(session, "context")); + let second_contexts = values(registry.register_context(session, "context-two")); + assert_eq!(contexts.len(), 1); + assert_eq!(second_contexts.len(), 1); + let context = contexts[0]; + let second_context = second_contexts[0]; + assert_eq!(registry.register_context(session, "context"), Ok(context)); + + let first_origins = values(Origin::parse("http://127.0.0.1:43127")); + let second_origins = values(Origin::parse("http://localhost:43127")); + assert_eq!(first_origins.len(), 1); + assert_eq!(second_origins.len(), 1); + let first_origin = &first_origins[0]; + let second_origin = &second_origins[0]; + assert!( + registry + .bind_node(session, context, first_origin, "node-a") + .is_ok() + ); + assert!( + registry + .bind_node(session, second_context, first_origin, "node-b") + .is_ok() + ); + assert_eq!( + registry.bind_node(session, context, second_origin, "node-a"), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + assert_eq!(registry.node_by_external.len(), 2); + assert!(registry.advance_document(context).is_ok()); + assert_eq!(registry.node_by_external.len(), 1); + assert!( + registry + .bind_node(session, context, second_origin, "node-a") + .is_ok() + ); + } + + #[test] + fn invalid_node_and_context_inputs_are_rejected() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + assert_eq!( + registry.register_context(session, ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_context( + session, + &"x".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1), + ), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + let contexts = values(registry.register_context(session, "context")); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(contexts.len(), 1); + assert_eq!(origins.len(), 1); + assert_eq!( + registry.bind_node(session, contexts[0], &origins[0], ""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.bind_node( + session, + contexts[0], + &origins[0], + &"x".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1), + ), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + } + + #[test] + fn browser_registry_errors_have_non_sensitive_deterministic_text() { + let expected_values = values(BrowserSessionId::new(1)); + let actual_values = values(BrowserSessionId::new(2)); + assert_eq!(expected_values.len(), 1); + assert_eq!(actual_values.len(), 1); + let errors = [ + BrowserRegistryError::InvalidExternalIdentifier, + BrowserRegistryError::UnknownBrowserSession, + BrowserRegistryError::UnknownBrowsingContext, + BrowserRegistryError::ContextSessionMismatch { + expected: expected_values[0], + actual: actual_values[0], + }, + BrowserRegistryError::ContextExternalIdentifierMismatch, + BrowserRegistryError::ContextOriginNotBound, + BrowserRegistryError::OriginChangedWithoutDocumentAdvance, + BrowserRegistryError::IdentifierSpaceExhausted, + BrowserRegistryError::DocumentEpochExhausted, + BrowserRegistryError::InternalAuthorityInvariant, + ]; + for error in errors { + let text = error.to_string(); + assert!(!text.is_empty()); + assert!(!text.contains("webdriver-session")); + } + } +} diff --git a/crates/originweave-core/src/browser_registry_coverage.rs b/crates/originweave-core/src/browser_registry_coverage.rs new file mode 100644 index 000000000..37cedd741 --- /dev/null +++ b/crates/originweave-core/src/browser_registry_coverage.rs @@ -0,0 +1,60 @@ +use crate::{BrowserAuthorityRegistry, BrowserRegistryError, BrowserSessionId, Origin}; + +fn values(result: Result) -> Vec { + result.into_iter().collect() +} + +#[test] +fn repeated_node_binding_exercises_the_unit_crate_existing_node_path() { + let mut registry = BrowserAuthorityRegistry::new(); + let sessions = values(registry.register_session("unit-session")); + assert_eq!(sessions.len(), 1); + let session = sessions[0]; + + let contexts = values(registry.register_context(session, "unit-context")); + assert_eq!(contexts.len(), 1); + let context = contexts[0]; + + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(origins.len(), 1); + let origin = &origins[0]; + + let first = values(registry.bind_nodes(session, context, origin, &["unit-node"])); + let repeated = values(registry.bind_nodes(session, context, origin, &["unit-node"])); + assert_eq!(first.len(), 1); + assert_eq!(repeated.len(), 1); + assert_eq!(first[0], repeated[0]); +} + +#[test] +fn session_authority_failures_are_exercised_in_the_unit_crate() { + let mut registry = BrowserAuthorityRegistry::new(); + let unknown_sessions = values(BrowserSessionId::new(999)); + assert_eq!(unknown_sessions.len(), 1); + let unknown = unknown_sessions[0]; + assert_eq!( + registry.register_context(unknown, "unknown-context"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let owner_sessions = values(registry.register_session("owner-session")); + let attacker_sessions = values(registry.register_session("attacker-session")); + assert_eq!(owner_sessions.len(), 1); + assert_eq!(attacker_sessions.len(), 1); + let owner = owner_sessions[0]; + let attacker = attacker_sessions[0]; + + let contexts = values(registry.register_context(owner, "owner-context")); + let origins = values(Origin::parse("http://127.0.0.1:43127")); + assert_eq!(contexts.len(), 1); + assert_eq!(origins.len(), 1); + let context = contexts[0]; + + assert_eq!( + registry.bind_nodes(attacker, context, &origins[0], &["unit-node"]), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) + ); +} diff --git a/crates/originweave-core/src/contracts.rs b/crates/originweave-core/src/contracts.rs new file mode 100644 index 000000000..88dd2e586 --- /dev/null +++ b/crates/originweave-core/src/contracts.rs @@ -0,0 +1,1065 @@ +//! Shared security and governance contracts for OriginWeave. +//! +//! The crate deliberately contains no browser-engine integration. It defines +//! small, deterministic value types that can be reused by the browser shell, +//! headless runtime, MCP adapter, and enterprise policy service. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +use std::collections::BTreeSet; +use std::fmt; +use std::net::{Ipv4Addr, Ipv6Addr}; + +/// A normalized web origin accepted by the OriginWeave trust boundary. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Origin { + canonical: String, +} + +impl Origin { + /// Parse one origin and reject paths, credentials, fragments, insecure + /// remote HTTP endpoints, and browser-special numeric host spellings. + pub fn parse(input: &str) -> Result { + if input.trim() != input + || input + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { + return Err(OriginError::InvalidAuthority); + } + + let Some((raw_scheme, authority)) = input.split_once("://") else { + return Err(OriginError::MissingScheme); + }; + let scheme = raw_scheme.to_ascii_lowercase(); + if scheme != "https" && scheme != "http" { + return Err(OriginError::UnsupportedScheme); + } + if authority.is_empty() { + return Err(OriginError::MissingAuthority); + } + if authority.contains('@') { + return Err(OriginError::UserInfoNotAllowed); + } + if authority + .chars() + .any(|character| matches!(character, '/' | '?' | '#')) + { + return Err(OriginError::PathNotAllowed); + } + + let (host, port, is_loopback) = parse_authority(authority)?; + if scheme == "http" && !is_loopback { + return Err(OriginError::InsecureRemoteOrigin); + } + let normalized_port = normalize_default_port(&scheme, port); + let canonical = match normalized_port { + Some(port_number) => format!("{scheme}://{host}:{port_number}"), + None => format!("{scheme}://{host}"), + }; + Ok(Self { canonical }) + } + + /// Return the normalized origin string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.canonical + } + + /// Return the validated lowercase origin scheme. + #[must_use] + pub fn scheme(&self) -> &str { + if self.canonical.starts_with("https://") { + "https" + } else { + "http" + } + } + + /// Return the validated canonical host without IPv6 brackets. + #[must_use] + pub fn host(&self) -> &str { + let authority = &self.canonical[self.scheme().len() + 3..]; + let bracketed = authority.starts_with('['); + let host_start = usize::from(bracketed); + let host_end = if bracketed { + authority.find(']').unwrap_or(authority.len()) + } else { + authority.find(':').unwrap_or(authority.len()) + }; + &authority[host_start..host_end] + } +} + +impl fmt::Display for Origin { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +fn normalize_default_port(scheme: &str, port: Option) -> Option { + match (scheme, port) { + ("https", Some(443)) | ("http", Some(80)) => None, + (_, other) => other, + } +} + +fn parse_authority(authority: &str) -> Result<(String, Option, bool), OriginError> { + if authority.starts_with('[') { + return parse_bracketed_ipv6(authority); + } + if authority.matches(':').count() > 1 { + return Err(OriginError::InvalidAuthority); + } + + let (host_text, port) = match authority.rsplit_once(':') { + Some((host, port_text)) => (host, Some(parse_port(port_text)?)), + None => (authority, None), + }; + let host = host_text.to_ascii_lowercase(); + if let Ok(address) = host.parse::() { + return Ok((host, port, address.is_loopback())); + } + if looks_like_browser_ipv4_host(&host) { + return Err(OriginError::AmbiguousNumericHost); + } + validate_dns_host(&host)?; + Ok((host.clone(), port, host == "localhost")) +} + +fn looks_like_browser_ipv4_host(host: &str) -> bool { + host.rsplit('.') + .next() + .is_some_and(looks_like_browser_ipv4_number) +} + +fn looks_like_browser_ipv4_number(label: &str) -> bool { + if label.is_empty() { + return false; + } + let lowercase = label.to_ascii_lowercase(); + if let Some(hexadecimal) = lowercase.strip_prefix("0x") { + return !hexadecimal.is_empty() && hexadecimal.bytes().all(|byte| byte.is_ascii_hexdigit()); + } + label.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn parse_bracketed_ipv6(authority: &str) -> Result<(String, Option, bool), OriginError> { + let Some(close_index) = authority.find(']') else { + return Err(OriginError::InvalidAuthority); + }; + let address_text = &authority[1..close_index]; + let address = address_text + .parse::() + .map_err(|_error| OriginError::InvalidAuthority)?; + let remainder = &authority[close_index + 1..]; + let port = if remainder.is_empty() { + None + } else if let Some(port_text) = remainder.strip_prefix(':') { + Some(parse_port(port_text)?) + } else { + return Err(OriginError::InvalidAuthority); + }; + Ok((format!("[{address}]"), port, address.is_loopback())) +} + +fn parse_port(port_text: &str) -> Result { + let port = port_text + .parse::() + .map_err(|_error| OriginError::InvalidPort)?; + if port == 0 { + return Err(OriginError::InvalidPort); + } + Ok(port) +} + +fn validate_dns_host(host: &str) -> Result<(), OriginError> { + if host.is_empty() { + return Err(OriginError::InvalidAuthority); + } + if host.len() > 253 { + return Err(OriginError::InvalidAuthority); + } + if !host.is_ascii() { + return Err(OriginError::InvalidAuthority); + } + if host.starts_with('.') || host.ends_with('.') { + return Err(OriginError::InvalidAuthority); + } + for label in host.split('.') { + if label.is_empty() { + return Err(OriginError::InvalidAuthority); + } + if label.len() > 63 { + return Err(OriginError::InvalidAuthority); + } + let bytes = label.as_bytes(); + if !bytes[0].is_ascii_alphanumeric() { + return Err(OriginError::InvalidAuthority); + } + if !bytes[bytes.len() - 1].is_ascii_alphanumeric() { + return Err(OriginError::InvalidAuthority); + } + if !bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') + { + return Err(OriginError::InvalidAuthority); + } + } + Ok(()) +} + +/// A reason that an origin string could not enter the trust boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OriginError { + /// The input did not contain a `scheme://` separator. + MissingScheme, + /// The scheme was neither HTTPS nor locally scoped HTTP. + UnsupportedScheme, + /// HTTP was requested for a non-loopback host. + InsecureRemoteOrigin, + /// No authority followed the scheme. + MissingAuthority, + /// User information appeared before the host. + UserInfoNotAllowed, + /// A path, query, or fragment was supplied where only an origin is valid. + PathNotAllowed, + /// The host or authority syntax was ambiguous or malformed. + InvalidAuthority, + /// A browser could reinterpret the host as a non-canonical IPv4 address. + AmbiguousNumericHost, + /// The explicit port was outside `1..=65535` or was not numeric. + InvalidPort, +} + +/// A nonzero identity for one active browser automation session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrowserSessionId(u64); + +impl BrowserSessionId { + /// Validate one adapter-supplied browser-session identifier. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(NodeHandleError::InvalidBrowserSessionId); + } + Ok(Self(value)) + } + + /// Return the validated browser-session identifier. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A nonzero identity for one independently navigable browser context. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BrowsingContextId(u64); + +impl BrowsingContextId { + /// Validate one adapter-supplied browsing-context identifier. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(NodeHandleError::InvalidBrowsingContextId); + } + Ok(Self(value)) + } + + /// Return the validated browsing-context identifier. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A nonzero identity for one observed browser document lifetime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DocumentEpoch(u64); + +impl DocumentEpoch { + /// Validate one adapter-supplied document epoch. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(NodeHandleError::InvalidDocumentEpoch); + } + Ok(Self(value)) + } + + /// Return the validated document epoch value. + #[must_use] + pub const fn value(self) -> u64 { + self.0 + } +} + +/// A node identity bound to the exact session, context, origin, and document that produced it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObservedNodeHandle { + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + document_epoch: DocumentEpoch, + node_id: u64, +} + +impl ObservedNodeHandle { + /// Create one authority-bound observed node handle from a nonzero adapter node identifier. + pub fn new( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: Origin, + document_epoch: DocumentEpoch, + node_id: u64, + ) -> Result { + if node_id == 0 { + return Err(NodeHandleError::InvalidNodeId); + } + Ok(Self { + browser_session, + browsing_context, + origin, + document_epoch, + node_id, + }) + } + + /// Return the browser session that produced the node observation. + #[must_use] + pub const fn browser_session(&self) -> BrowserSessionId { + self.browser_session + } + + /// Return the browsing context that produced the node observation. + #[must_use] + pub const fn browsing_context(&self) -> BrowsingContextId { + self.browsing_context + } + + /// Return the canonical origin that produced the node observation. + #[must_use] + pub const fn origin(&self) -> &Origin { + &self.origin + } + + /// Return the document epoch that produced the node observation. + #[must_use] + pub const fn document_epoch(&self) -> DocumentEpoch { + self.document_epoch + } + + /// Return the adapter-local nonzero node identifier. + #[must_use] + pub const fn node_id(&self) -> u64 { + self.node_id + } + + /// Reject use when the session, browsing context, origin, or document epoch has changed. + pub fn validate_current( + &self, + current_session: BrowserSessionId, + current_context: BrowsingContextId, + current_origin: &Origin, + current_epoch: DocumentEpoch, + ) -> Result<(), NodeHandleError> { + if self.browser_session != current_session { + return Err(NodeHandleError::BrowserSessionMismatch { + observed: self.browser_session, + current: current_session, + }); + } + if self.browsing_context != current_context { + return Err(NodeHandleError::BrowsingContextMismatch { + observed: self.browsing_context, + current: current_context, + }); + } + if &self.origin != current_origin { + return Err(NodeHandleError::OriginMismatch); + } + if self.document_epoch != current_epoch { + return Err(NodeHandleError::StaleDocumentEpoch { + observed: self.document_epoch, + current: current_epoch, + }); + } + Ok(()) + } +} + +/// A failure to construct or reuse an authority- and document-bound node handle safely. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NodeHandleError { + /// Browser-session identifiers are one-based and zero was supplied. + InvalidBrowserSessionId, + /// Browsing-context identifiers are one-based and zero was supplied. + InvalidBrowsingContextId, + /// Document epochs are one-based and zero was supplied. + InvalidDocumentEpoch, + /// Adapter-local node identifiers are one-based and zero was supplied. + InvalidNodeId, + /// The node handle belongs to a different browser automation session. + BrowserSessionMismatch { + /// Session that originally produced the node handle. + observed: BrowserSessionId, + /// Session currently active for the requested action. + current: BrowserSessionId, + }, + /// The node handle belongs to a different independently navigable context. + BrowsingContextMismatch { + /// Context that originally produced the node handle. + observed: BrowsingContextId, + /// Context currently active for the requested action. + current: BrowsingContextId, + }, + /// The browser context is now at a different canonical origin. + OriginMismatch, + /// The browser context is now at a different document epoch. + StaleDocumentEpoch { + /// Epoch that originally produced the node handle. + observed: DocumentEpoch, + /// Epoch currently active in the browser context. + current: DocumentEpoch, + }, +} + +impl fmt::Display for NodeHandleError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidBrowserSessionId => { + formatter.write_str("browser session identifier must be nonzero") + } + Self::InvalidBrowsingContextId => { + formatter.write_str("browsing context identifier must be nonzero") + } + Self::InvalidDocumentEpoch => formatter.write_str("document epoch must be nonzero"), + Self::InvalidNodeId => formatter.write_str("observed node identifier must be nonzero"), + Self::BrowserSessionMismatch { observed, current } => write!( + formatter, + "observed node browser session {} does not match current session {}", + observed.value(), + current.value() + ), + Self::BrowsingContextMismatch { observed, current } => write!( + formatter, + "observed node browsing context {} does not match current context {}", + observed.value(), + current.value() + ), + Self::OriginMismatch => { + formatter.write_str("observed node origin does not match the current origin") + } + Self::StaleDocumentEpoch { observed, current } => write!( + formatter, + "observed node document epoch {} is stale; current epoch is {}", + observed.value(), + current.value() + ), + } + } +} + +impl std::error::Error for NodeHandleError {} + +/// An immutable digest of the complete canonical action intent. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ActionIntentDigest { + canonical: String, +} + +impl ActionIntentDigest { + /// Parse a lowercase `sha256:` digest of the complete canonical intent. + pub fn parse(input: &str) -> Result { + let Some(hexadecimal) = input.strip_prefix("sha256:") else { + return Err(ActionIntentDigestError::InvalidFormat); + }; + if hexadecimal.len() != 64 + || !hexadecimal + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ActionIntentDigestError::InvalidFormat); + } + Ok(Self { + canonical: input.to_owned(), + }) + } + + /// Return the canonical lowercase digest. + #[must_use] + pub fn as_str(&self) -> &str { + &self.canonical + } +} + +/// A validation error for an action-intent digest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionIntentDigestError { + /// The value was not `sha256:` followed by 64 lowercase hexadecimal digits. + InvalidFormat, +} + +/// The browser execution mode that owns an action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SessionMode { + /// A person controls the browser without agent execution privileges. + Human, + /// An agent assists a person while write actions remain governed. + Assist, + /// An isolated task session is delegated to an agent. + AgentTask, + /// A read-only crawler performs policy-bounded collection. + Crawler, +} + +/// The declared business purpose of one browser execution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExecutionPurpose { + /// Public content is collected under crawler policy. + PublicCrawl, + /// A person delegated a bounded task in their own context. + UserDelegatedTask, + /// An enterprise policy authorized a managed task. + EnterpriseAuthorizedTask, + /// The action is running in a non-production test environment. + TestingEnvironment, +} + +/// The trust class of the instruction that proposed an action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum InstructionSource { + /// A human user supplied the instruction. + User, + /// A managed enterprise policy supplied the instruction. + EnterprisePolicy, + /// Untrusted page or document content supplied the instruction. + WebContent, +} + +/// The result of applying a robots-exclusion policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RobotsDecision { + /// The requested crawl is explicitly allowed. + Allowed, + /// The requested crawl is explicitly disallowed. + Disallowed, + /// The policy could not be fetched or interpreted safely. + Unknown, + /// Robots policy was not evaluated for this execution purpose. + NotApplicable, +} + +/// How secret material is delivered to a browser action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SecretDelivery { + /// The action carries no secret material. + None, + /// A trusted broker resolves an opaque secret handle outside the model. + BrokerHandle, + /// A raw secret value would be exposed directly to the caller. + RawValue, +} + +/// The ordered risk class assigned to an action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RiskClass { + /// Read-only observation with no state change. + R0, + /// Low-risk navigation or local retrieval. + R1, + /// Reversible preparation such as creating a draft. + R2, + /// External submission or sensitive interaction requiring approval. + R3, + /// High-impact purchase, deletion, or permission change. + R4, + /// Legal or similarly non-delegable consent. + R5, +} + +impl RiskClass { + /// Return whether the risk class requires approval before execution. + #[must_use] + pub const fn requires_approval(self) -> bool { + matches!(self, Self::R3 | Self::R4 | Self::R5) + } +} + +/// A capability that may be granted to an isolated agent session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Capability { + /// Observe a page's governed semantic representation. + Observe, + /// Extract structured information from allowed evidence. + Extract, + /// Navigate to an allowed origin. + Navigate, + /// Download a resource from an allowed origin. + Download, + /// Prepare a reversible draft. + Draft, + /// Submit data to an allowed origin. + Submit, + /// Upload a pre-approved artifact. + Upload, + /// Fill a secret through the trusted secret broker. + FillSecret, + /// Complete a purchase after approval. + Purchase, + /// Delete a remote object after approval. + Delete, + /// Change a permission after approval. + ManagePermission, + /// Record legal consent, which agents cannot perform autonomously. + LegalConsent, +} + +/// A typed browser action exposed to policy evaluation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ActionKind { + /// Observe governed page state. + Observe, + /// Extract structured data. + Extract, + /// Navigate the browser. + Navigate, + /// Download a resource. + Download, + /// Create or update a reversible draft. + Draft, + /// Submit data externally. + Submit, + /// Upload an approved file. + Upload, + /// Fill a secret using an opaque broker handle. + FillSecret, + /// Complete a purchase. + Purchase, + /// Delete remote state. + Delete, + /// Change access permissions. + ManagePermission, + /// Accept legally binding terms. + LegalConsent, +} + +impl ActionKind { + /// Return the action's fixed risk classification. + #[must_use] + pub const fn risk_class(self) -> RiskClass { + match self { + Self::Observe | Self::Extract => RiskClass::R0, + Self::Navigate | Self::Download => RiskClass::R1, + Self::Draft => RiskClass::R2, + Self::Submit | Self::Upload | Self::FillSecret => RiskClass::R3, + Self::Purchase | Self::Delete | Self::ManagePermission => RiskClass::R4, + Self::LegalConsent => RiskClass::R5, + } + } + + /// Return the capability required to request this action. + #[must_use] + pub const fn required_capability(self) -> Capability { + match self { + Self::Observe => Capability::Observe, + Self::Extract => Capability::Extract, + Self::Navigate => Capability::Navigate, + Self::Download => Capability::Download, + Self::Draft => Capability::Draft, + Self::Submit => Capability::Submit, + Self::Upload => Capability::Upload, + Self::FillSecret => Capability::FillSecret, + Self::Purchase => Capability::Purchase, + Self::Delete => Capability::Delete, + Self::ManagePermission => Capability::ManagePermission, + Self::LegalConsent => Capability::LegalConsent, + } + } + + /// Return whether execution can mutate browser or remote state. + #[must_use] + pub const fn mutates_state(self) -> bool { + !matches!( + self, + Self::Observe | Self::Extract | Self::Navigate | Self::Download + ) + } + + /// Return whether this action is designed to resolve a brokered secret. + #[must_use] + pub const fn uses_secret(self) -> bool { + matches!(self, Self::FillSecret) + } +} + +/// The exact action, target origin, and complete intent covered by an approval. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalScope { + action: ActionKind, + target_origin: Origin, + intent_digest: ActionIntentDigest, +} + +impl ApprovalScope { + /// Create one exact approval scope. + #[must_use] + pub const fn new( + action: ActionKind, + target_origin: Origin, + intent_digest: ActionIntentDigest, + ) -> Self { + Self { + action, + target_origin, + intent_digest, + } + } + + /// Return the approved action kind. + #[must_use] + pub const fn action(&self) -> ActionKind { + self.action + } + + /// Return the approved target origin. + #[must_use] + pub const fn target_origin(&self) -> &Origin { + &self.target_origin + } + + /// Return the approved complete-intent digest. + #[must_use] + pub const fn intent_digest(&self) -> &ActionIntentDigest { + &self.intent_digest + } +} + +/// Evidence that a high-risk action was approved for an exact scope. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApprovalEvidence { + /// No approval was supplied. + None, + /// A person confirmed the exact action, target, and complete intent. + UserConfirmed(ApprovalScope), + /// A managed policy approved the exact action, target, and complete intent. + EnterprisePolicy(ApprovalScope), +} + +impl ApprovalEvidence { + /// Return whether this evidence authorizes the exact required scope. + #[must_use] + pub fn authorizes(&self, required: &ApprovalScope) -> bool { + match self { + Self::None => false, + Self::UserConfirmed(scope) | Self::EnterprisePolicy(scope) => scope == required, + } + } +} + +/// A complete typed request presented to the policy engine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionRequest { + action: ActionKind, + source_origin: Origin, + target_origin: Origin, + instruction_source: InstructionSource, + secret_delivery: SecretDelivery, + intent_digest: ActionIntentDigest, +} + +impl ActionRequest { + /// Create one action request without executing it. + #[must_use] + pub const fn new( + action: ActionKind, + source_origin: Origin, + target_origin: Origin, + instruction_source: InstructionSource, + secret_delivery: SecretDelivery, + intent_digest: ActionIntentDigest, + ) -> Self { + Self { + action, + source_origin, + target_origin, + instruction_source, + secret_delivery, + intent_digest, + } + } + + /// Return the requested action. + #[must_use] + pub const fn action(&self) -> ActionKind { + self.action + } + + /// Return the origin that currently owns the browser context. + #[must_use] + pub const fn source_origin(&self) -> &Origin { + &self.source_origin + } + + /// Return the origin affected by the action. + #[must_use] + pub const fn target_origin(&self) -> &Origin { + &self.target_origin + } + + /// Return the trust class of the proposing instruction. + #[must_use] + pub const fn instruction_source(&self) -> InstructionSource { + self.instruction_source + } + + /// Return how secret material would be delivered. + #[must_use] + pub const fn secret_delivery(&self) -> SecretDelivery { + self.secret_delivery + } + + /// Return the digest of the complete canonical action intent. + #[must_use] + pub const fn intent_digest(&self) -> &ActionIntentDigest { + &self.intent_digest + } +} + +/// Immutable grants and mutable evidence used for one policy decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyContext { + mode: SessionMode, + purpose: ExecutionPurpose, + capabilities: BTreeSet, + read_origins: BTreeSet, + write_origins: BTreeSet, + robots_decision: RobotsDecision, + approval: ApprovalEvidence, +} + +impl PolicyContext { + /// Create one policy context from explicitly granted capabilities and origins. + #[must_use] + pub const fn new( + mode: SessionMode, + purpose: ExecutionPurpose, + capabilities: BTreeSet, + read_origins: BTreeSet, + write_origins: BTreeSet, + robots_decision: RobotsDecision, + approval: ApprovalEvidence, + ) -> Self { + Self { + mode, + purpose, + capabilities, + read_origins, + write_origins, + robots_decision, + approval, + } + } + + /// Return the browser execution mode. + #[must_use] + pub const fn mode(&self) -> SessionMode { + self.mode + } + + /// Return the declared execution purpose. + #[must_use] + pub const fn purpose(&self) -> ExecutionPurpose { + self.purpose + } + + /// Return the granted capabilities. + #[must_use] + pub const fn capabilities(&self) -> &BTreeSet { + &self.capabilities + } + + /// Return the origins that may be read. + #[must_use] + pub const fn read_origins(&self) -> &BTreeSet { + &self.read_origins + } + + /// Return the origins that may be mutated. + #[must_use] + pub const fn write_origins(&self) -> &BTreeSet { + &self.write_origins + } + + /// Return the robots-exclusion decision. + #[must_use] + pub const fn robots_decision(&self) -> RobotsDecision { + self.robots_decision + } + + /// Replace robots evidence after a fresh policy lookup. + pub const fn set_robots_decision(&mut self, decision: RobotsDecision) { + self.robots_decision = decision; + } + + /// Return the supplied approval evidence. + #[must_use] + pub const fn approval(&self) -> &ApprovalEvidence { + &self.approval + } + + /// Replace approval evidence after a user or enterprise decision. + pub fn set_approval(&mut self, approval: ApprovalEvidence) { + self.approval = approval; + } +} + +/// A canonical Chromium extension identifier admitted to OriginWeave policy. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExtensionId { + canonical: String, +} + +impl ExtensionId { + /// Parse one canonical 32-character lowercase Chromium extension identifier. + /// + /// Chromium extension identifiers use only the lowercase `a` through `p` + /// alphabet. OriginWeave rejects any non-canonical spelling rather than + /// normalizing caller-controlled identity text. + pub fn parse(input: &str) -> Result { + if input.len() != 32 { + return Err(ExtensionIdError::InvalidExtensionId); + } + if !input.bytes().all(|byte| (b'a'..=b'p').contains(&byte)) { + return Err(ExtensionIdError::InvalidExtensionId); + } + Ok(Self { + canonical: input.to_owned(), + }) + } + + /// Return the canonical extension identifier. + #[must_use] + pub fn as_str(&self) -> &str { + &self.canonical + } +} + +/// A validation error for a Chromium extension identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtensionIdError { + /// The value was not exactly 32 lowercase characters from `a` through `p`. + InvalidExtensionId, +} + +/// An OriginWeave Agent capability that a browser extension may request explicitly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtensionAgentCapability { + /// Observe the governed semantic representation of the exact current context. + ObserveCurrentContext, + /// Propose a typed action for independent OriginWeave policy evaluation. + ProposeTypedAction, +} + +/// An explicit host-originated grant from one extension to bounded Agent capabilities. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAgentGrant { + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capabilities: BTreeSet, +} + +impl ExtensionAgentGrant { + /// Build an exact extension-to-Agent grant for one browser session and context. + #[must_use] + pub fn new( + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capabilities: I, + ) -> Self + where + I: IntoIterator, + { + Self { + extension_id, + browser_session, + browsing_context, + capabilities: capabilities.into_iter().collect(), + } + } +} + +/// One extension request to use a bounded OriginWeave Agent capability. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionAccessRequest { + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capability: ExtensionAgentCapability, +} + +impl ExtensionAccessRequest { + /// Build one exact extension capability request without granting authority. + #[must_use] + pub const fn new( + extension_id: ExtensionId, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + capability: ExtensionAgentCapability, + ) -> Self { + Self { + extension_id, + browser_session, + browsing_context, + capability, + } + } +} + +/// Result of evaluating an extension request against one explicit Agent grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtensionAccessDecision { + /// The exact extension, session, context, and capability are explicitly granted. + Allow, + /// No explicit extension-to-Agent grant was supplied. + DenyMissingGrant, + /// The request belongs to a different extension identity. + DenyExtensionMismatch, + /// The request belongs to a different browser automation session. + DenyBrowserSessionMismatch, + /// The request belongs to a different independently navigable browser context. + DenyBrowsingContextMismatch, + /// The extension grant does not contain the requested OriginWeave capability. + DenyCapabilityNotGranted, +} + +/// Evaluate extension Agent access without inheriting ambient Chrome permissions. +/// +/// A Chrome extension permission, installation state, or page capability is never +/// consulted here. A future Chromium adapter must construct a host-originated +/// [`ExtensionAgentGrant`] explicitly and re-evaluate the exact session/context +/// request at the boundary where Agent authority would otherwise cross. +#[must_use] +pub fn evaluate_extension_access( + request: &ExtensionAccessRequest, + grant: Option<&ExtensionAgentGrant>, +) -> ExtensionAccessDecision { + let Some(grant) = grant else { + return ExtensionAccessDecision::DenyMissingGrant; + }; + if request.extension_id != grant.extension_id { + return ExtensionAccessDecision::DenyExtensionMismatch; + } + if request.browser_session != grant.browser_session { + return ExtensionAccessDecision::DenyBrowserSessionMismatch; + } + if request.browsing_context != grant.browsing_context { + return ExtensionAccessDecision::DenyBrowsingContextMismatch; + } + if !grant.capabilities.contains(&request.capability) { + return ExtensionAccessDecision::DenyCapabilityNotGranted; + } + ExtensionAccessDecision::Allow +} diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index e33a7e7e5..b9d69c8b0 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -1,1094 +1,100 @@ //! Shared security and governance contracts for OriginWeave. //! -//! The crate deliberately contains no browser-engine integration. It defines -//! small, deterministic value types that can be reused by the browser shell, -//! headless runtime, MCP adapter, and enterprise policy service. +//! This crate keeps the long-lived value contracts in `contracts` and the +//! browser protocol/identifier boundaries in focused modules so browser +//! adapters can evolve without turning raw CDP or WebDriver metadata into +//! OriginWeave authority. +//! +//! Raw adapter-local node identifiers must not be mintable through the public +//! registry API. Public callers must enter through the reviewed semantic-node +//! admission path instead: +//! +//! ```compile_fail +//! use originweave_core::{BrowserAuthorityRegistry, Origin}; +//! +//! let mut registry = BrowserAuthorityRegistry::new(); +//! let session = registry.register_session("webdriver-session")?; +//! let context = registry.register_context(session, "top-level-context")?; +//! let origin = Origin::parse("http://127.0.0.1:43127")?; +//! let _handle = registry.bind_node(session, context, &origin, "backend-node-17")?; +//! # Ok::<(), Box>(()) +//! ``` #![forbid(unsafe_code)] #![deny(missing_docs)] -use std::collections::BTreeSet; -use std::fmt; -use std::net::{Ipv4Addr, Ipv6Addr}; - -/// A normalized web origin accepted by the OriginWeave trust boundary. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Origin { - canonical: String, -} - -impl Origin { - /// Parse one origin and reject paths, credentials, fragments, insecure - /// remote HTTP endpoints, and browser-special numeric host spellings. - pub fn parse(input: &str) -> Result { - if input.trim() != input - || input - .chars() - .any(|character| character.is_control() || character.is_whitespace()) - { - return Err(OriginError::InvalidAuthority); - } - - let Some((raw_scheme, authority)) = input.split_once("://") else { - return Err(OriginError::MissingScheme); - }; - let scheme = raw_scheme.to_ascii_lowercase(); - if scheme != "https" && scheme != "http" { - return Err(OriginError::UnsupportedScheme); - } - if authority.is_empty() { - return Err(OriginError::MissingAuthority); - } - if authority.contains('@') { - return Err(OriginError::UserInfoNotAllowed); - } - if authority - .chars() - .any(|character| matches!(character, '/' | '?' | '#')) - { - return Err(OriginError::PathNotAllowed); - } - - let (host, port, is_loopback) = parse_authority(authority)?; - if scheme == "http" && !is_loopback { - return Err(OriginError::InsecureRemoteOrigin); - } - let normalized_port = normalize_default_port(&scheme, port); - let canonical = match normalized_port { - Some(port_number) => format!("{scheme}://{host}:{port_number}"), - None => format!("{scheme}://{host}"), - }; - Ok(Self { canonical }) - } - - /// Return the normalized origin string. - #[must_use] - pub fn as_str(&self) -> &str { - &self.canonical - } - - /// Return the validated lowercase origin scheme. - #[must_use] - pub fn scheme(&self) -> &str { - if self.canonical.starts_with("https://") { - "https" - } else { - "http" - } - } - - /// Return the validated canonical host without IPv6 brackets. - #[must_use] - pub fn host(&self) -> &str { - let authority = &self.canonical[self.scheme().len() + 3..]; - let bracketed = authority.starts_with('['); - let host_start = usize::from(bracketed); - let host_end = if bracketed { - authority.find(']').unwrap_or(authority.len()) - } else { - authority.find(':').unwrap_or(authority.len()) - }; - &authority[host_start..host_end] - } -} - -impl fmt::Display for Origin { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -fn normalize_default_port(scheme: &str, port: Option) -> Option { - match (scheme, port) { - ("https", Some(443)) | ("http", Some(80)) => None, - (_, other) => other, - } -} - -fn parse_authority(authority: &str) -> Result<(String, Option, bool), OriginError> { - if authority.starts_with('[') { - return parse_bracketed_ipv6(authority); - } - if authority.matches(':').count() > 1 { - return Err(OriginError::InvalidAuthority); - } - - let (host_text, port) = match authority.rsplit_once(':') { - Some((host, port_text)) => (host, Some(parse_port(port_text)?)), - None => (authority, None), - }; - let host = host_text.to_ascii_lowercase(); - if let Ok(address) = host.parse::() { - return Ok((host, port, address.is_loopback())); - } - if looks_like_browser_ipv4_host(&host) { - return Err(OriginError::AmbiguousNumericHost); - } - validate_dns_host(&host)?; - Ok((host.clone(), port, host == "localhost")) -} - -fn looks_like_browser_ipv4_host(host: &str) -> bool { - host.rsplit('.') - .next() - .is_some_and(looks_like_browser_ipv4_number) -} - -fn looks_like_browser_ipv4_number(label: &str) -> bool { - if label.is_empty() { - return false; - } - let lowercase = label.to_ascii_lowercase(); - if let Some(hexadecimal) = lowercase.strip_prefix("0x") { - return !hexadecimal.is_empty() && hexadecimal.bytes().all(|byte| byte.is_ascii_hexdigit()); - } - label.bytes().all(|byte| byte.is_ascii_digit()) -} - -fn parse_bracketed_ipv6(authority: &str) -> Result<(String, Option, bool), OriginError> { - let Some(close_index) = authority.find(']') else { - return Err(OriginError::InvalidAuthority); - }; - let address_text = &authority[1..close_index]; - let address = address_text - .parse::() - .map_err(|_error| OriginError::InvalidAuthority)?; - let remainder = &authority[close_index + 1..]; - let port = if remainder.is_empty() { - None - } else if let Some(port_text) = remainder.strip_prefix(':') { - Some(parse_port(port_text)?) - } else { - return Err(OriginError::InvalidAuthority); - }; - Ok((format!("[{address}]"), port, address.is_loopback())) -} - -fn parse_port(port_text: &str) -> Result { - if port_text.is_empty() || !port_text.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(OriginError::InvalidPort); - } - let port = port_text - .parse::() - .map_err(|_error| OriginError::InvalidPort)?; - if port == 0 { - return Err(OriginError::InvalidPort); - } - Ok(port) -} - -fn validate_dns_host(host: &str) -> Result<(), OriginError> { - if host.is_empty() { - return Err(OriginError::InvalidAuthority); - } - if host.len() > 253 { - return Err(OriginError::InvalidAuthority); - } - if !host.is_ascii() { - return Err(OriginError::InvalidAuthority); - } - if host.starts_with('.') || host.ends_with('.') { - return Err(OriginError::InvalidAuthority); - } - for label in host.split('.') { - if label.is_empty() { - return Err(OriginError::InvalidAuthority); - } - if label.len() > 63 { - return Err(OriginError::InvalidAuthority); - } - let bytes = label.as_bytes(); - if !bytes[0].is_ascii_alphanumeric() { - return Err(OriginError::InvalidAuthority); - } - if !bytes[bytes.len() - 1].is_ascii_alphanumeric() { - return Err(OriginError::InvalidAuthority); - } - if !bytes - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') - { - return Err(OriginError::InvalidAuthority); - } - } - Ok(()) -} - -/// A reason that an origin string could not enter the trust boundary. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OriginError { - /// The input did not contain a `scheme://` separator. - MissingScheme, - /// The scheme was neither HTTPS nor locally scoped HTTP. - UnsupportedScheme, - /// HTTP was requested for a non-loopback host. - InsecureRemoteOrigin, - /// No authority followed the scheme. - MissingAuthority, - /// User information appeared before the host. - UserInfoNotAllowed, - /// A path, query, or fragment was supplied where only an origin is valid. - PathNotAllowed, - /// The host or authority syntax was ambiguous or malformed. - InvalidAuthority, - /// A browser could reinterpret the host as a non-canonical IPv4 address. - AmbiguousNumericHost, - /// The explicit port was outside `1..=65535` or was not numeric. - InvalidPort, -} - -/// A nonzero identity for one active browser automation session. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct BrowserSessionId(u64); - -impl BrowserSessionId { - /// Validate one adapter-supplied browser-session identifier. - pub const fn new(value: u64) -> Result { - if value == 0 { - return Err(NodeHandleError::InvalidBrowserSessionId); - } - Ok(Self(value)) - } - - /// Return the validated browser-session identifier. - #[must_use] - pub const fn value(self) -> u64 { - self.0 - } -} - -/// A nonzero identity for one independently navigable browser context. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct BrowsingContextId(u64); - -impl BrowsingContextId { - /// Validate one adapter-supplied browsing-context identifier. - pub const fn new(value: u64) -> Result { - if value == 0 { - return Err(NodeHandleError::InvalidBrowsingContextId); - } - Ok(Self(value)) - } - - /// Return the validated browsing-context identifier. - #[must_use] - pub const fn value(self) -> u64 { - self.0 - } -} - -/// A nonzero identity for one observed browser document lifetime. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct DocumentEpoch(u64); - -impl DocumentEpoch { - /// Validate one adapter-supplied document epoch. - pub const fn new(value: u64) -> Result { - if value == 0 { - return Err(NodeHandleError::InvalidDocumentEpoch); - } - Ok(Self(value)) - } - - /// Return the validated document epoch value. - #[must_use] - pub const fn value(self) -> u64 { - self.0 - } -} - -/// A node identity bound to the exact session, context, origin, and document that produced it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ObservedNodeHandle { - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: Origin, - document_epoch: DocumentEpoch, - node_id: u64, -} - -impl ObservedNodeHandle { - /// Create one authority-bound observed node handle from a nonzero adapter node identifier. - pub fn new( - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: Origin, - document_epoch: DocumentEpoch, - node_id: u64, - ) -> Result { - if node_id == 0 { - return Err(NodeHandleError::InvalidNodeId); - } - Ok(Self { - browser_session, - browsing_context, - origin, - document_epoch, - node_id, - }) - } - - /// Return the browser session that produced the node observation. - #[must_use] - pub const fn browser_session(&self) -> BrowserSessionId { - self.browser_session - } - - /// Return the browsing context that produced the node observation. - #[must_use] - pub const fn browsing_context(&self) -> BrowsingContextId { - self.browsing_context - } - - /// Return the canonical origin that produced the node observation. - #[must_use] - pub const fn origin(&self) -> &Origin { - &self.origin - } - - /// Return the document epoch that produced the node observation. - #[must_use] - pub const fn document_epoch(&self) -> DocumentEpoch { - self.document_epoch - } - - /// Return the adapter-local nonzero node identifier. - #[must_use] - pub const fn node_id(&self) -> u64 { - self.node_id - } - - /// Reject use when the session, browsing context, origin, or document epoch has changed. - pub fn validate_current( - &self, - current_session: BrowserSessionId, - current_context: BrowsingContextId, - current_origin: &Origin, - current_epoch: DocumentEpoch, - ) -> Result<(), NodeHandleError> { - if self.browser_session != current_session { - return Err(NodeHandleError::BrowserSessionMismatch { - observed: self.browser_session, - current: current_session, - }); - } - if self.browsing_context != current_context { - return Err(NodeHandleError::BrowsingContextMismatch { - observed: self.browsing_context, - current: current_context, - }); - } - if &self.origin != current_origin { - return Err(NodeHandleError::OriginMismatch); - } - if self.document_epoch != current_epoch { - return Err(NodeHandleError::StaleDocumentEpoch { - observed: self.document_epoch, - current: current_epoch, - }); - } - Ok(()) - } -} - -/// A failure to construct or reuse an authority- and document-bound node handle safely. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NodeHandleError { - /// Browser-session identifiers are one-based and zero was supplied. - InvalidBrowserSessionId, - /// Browsing-context identifiers are one-based and zero was supplied. - InvalidBrowsingContextId, - /// Document epochs are one-based and zero was supplied. - InvalidDocumentEpoch, - /// Adapter-local node identifiers are one-based and zero was supplied. - InvalidNodeId, - /// The node handle belongs to a different browser automation session. - BrowserSessionMismatch { - /// Session that originally produced the node handle. - observed: BrowserSessionId, - /// Session currently active for the requested action. - current: BrowserSessionId, - }, - /// The node handle belongs to a different independently navigable context. - BrowsingContextMismatch { - /// Context that originally produced the node handle. - observed: BrowsingContextId, - /// Context currently active for the requested action. - current: BrowsingContextId, - }, - /// The browser context is now at a different canonical origin. - OriginMismatch, - /// The browser context is now at a different document epoch. - StaleDocumentEpoch { - /// Epoch that originally produced the node handle. - observed: DocumentEpoch, - /// Epoch currently active in the browser context. - current: DocumentEpoch, - }, -} - -impl fmt::Display for NodeHandleError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidBrowserSessionId => { - formatter.write_str("browser session identifier must be nonzero") - } - Self::InvalidBrowsingContextId => { - formatter.write_str("browsing context identifier must be nonzero") - } - Self::InvalidDocumentEpoch => formatter.write_str("document epoch must be nonzero"), - Self::InvalidNodeId => formatter.write_str("observed node identifier must be nonzero"), - Self::BrowserSessionMismatch { observed, current } => write!( - formatter, - "observed node browser session {} does not match current session {}", - observed.value(), - current.value() - ), - Self::BrowsingContextMismatch { observed, current } => write!( - formatter, - "observed node browsing context {} does not match current context {}", - observed.value(), - current.value() - ), - Self::OriginMismatch => { - formatter.write_str("observed node origin does not match the current origin") - } - Self::StaleDocumentEpoch { observed, current } => write!( - formatter, - "observed node document epoch {} is stale; current epoch is {}", - observed.value(), - current.value() - ), - } - } -} - -impl std::error::Error for NodeHandleError {} - -/// An immutable digest of the complete canonical action intent. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct ActionIntentDigest { - canonical: String, -} - -impl ActionIntentDigest { - /// Parse a lowercase `sha256:` digest of the complete canonical intent. - pub fn parse(input: &str) -> Result { - let Some(hexadecimal) = input.strip_prefix("sha256:") else { - return Err(ActionIntentDigestError::InvalidFormat); - }; - if hexadecimal.len() != 64 - || !hexadecimal - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err(ActionIntentDigestError::InvalidFormat); - } - Ok(Self { - canonical: input.to_owned(), - }) - } - - /// Return the canonical lowercase digest. - #[must_use] - pub fn as_str(&self) -> &str { - &self.canonical - } -} - -/// A validation error for an action-intent digest. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ActionIntentDigestError { - /// The value was not `sha256:` followed by 64 lowercase hexadecimal digits. - InvalidFormat, -} - -/// The browser execution mode that owns an action. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum SessionMode { - /// A person controls the browser without agent execution privileges. - Human, - /// An agent assists a person while write actions remain governed. - Assist, - /// An isolated task session is delegated to an agent. - AgentTask, - /// A read-only crawler performs policy-bounded collection. - Crawler, -} - -/// The declared business purpose of one browser execution. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ExecutionPurpose { - /// Public content is collected under crawler policy. - PublicCrawl, - /// A person delegated a bounded task in their own context. - UserDelegatedTask, - /// An enterprise policy authorized a managed task. - EnterpriseAuthorizedTask, - /// The action is running in a non-production test environment. - TestingEnvironment, -} - -/// The trust class of the instruction that proposed an action. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum InstructionSource { - /// A human user supplied the instruction. - User, - /// A managed enterprise policy supplied the instruction. - EnterprisePolicy, - /// Untrusted page or document content supplied the instruction. - WebContent, -} - -/// The result of applying a robots-exclusion policy. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum RobotsDecision { - /// The requested crawl is explicitly allowed. - Allowed, - /// The requested crawl is explicitly disallowed. - Disallowed, - /// The policy could not be fetched or interpreted safely. - Unknown, - /// Robots policy was not evaluated for this execution purpose. - NotApplicable, -} - -/// How secret material is delivered to a browser action. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum SecretDelivery { - /// The action carries no secret material. - None, - /// A trusted broker resolves an opaque secret handle outside the model. - BrokerHandle, - /// A raw secret value would be exposed directly to the caller. - RawValue, -} - -/// The ordered risk class assigned to an action. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum RiskClass { - /// Read-only observation with no state change. - R0, - /// Low-risk navigation or local retrieval. - R1, - /// Reversible preparation such as creating a draft. - R2, - /// External submission or sensitive interaction requiring approval. - R3, - /// High-impact purchase, deletion, or permission change. - R4, - /// Legal or similarly non-delegable consent. - R5, -} - -impl RiskClass { - /// Return whether the risk class requires approval before execution. - #[must_use] - pub const fn requires_approval(self) -> bool { - matches!(self, Self::R3 | Self::R4 | Self::R5) - } -} - -/// A capability that may be granted to an isolated agent session. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Capability { - /// Observe a page's governed semantic representation. - Observe, - /// Extract structured information from allowed evidence. - Extract, - /// Navigate to an allowed origin. - Navigate, - /// Download a resource from an allowed origin. - Download, - /// Prepare a reversible draft. - Draft, - /// Submit data to an allowed origin. - Submit, - /// Upload a pre-approved artifact. - Upload, - /// Fill a secret through the trusted secret broker. - FillSecret, - /// Complete a purchase after approval. - Purchase, - /// Delete a remote object after approval. - Delete, - /// Change a permission after approval. - ManagePermission, - /// Record legal consent, which agents cannot perform autonomously. - LegalConsent, -} - -/// A typed browser action exposed to policy evaluation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ActionKind { - /// Observe governed page state. - Observe, - /// Extract structured data. - Extract, - /// Navigate the browser. - Navigate, - /// Download a resource. - Download, - /// Create or update a reversible draft. - Draft, - /// Submit data externally. - Submit, - /// Upload an approved file. - Upload, - /// Fill a secret using an opaque broker handle. - FillSecret, - /// Complete a purchase. - Purchase, - /// Delete remote state. - Delete, - /// Change access permissions. - ManagePermission, - /// Accept legally binding terms. - LegalConsent, -} - -impl ActionKind { - /// Return the action's fixed risk classification. - #[must_use] - pub const fn risk_class(self) -> RiskClass { - match self { - Self::Observe | Self::Extract => RiskClass::R0, - Self::Navigate | Self::Download => RiskClass::R1, - Self::Draft => RiskClass::R2, - Self::Submit | Self::Upload | Self::FillSecret => RiskClass::R3, - Self::Purchase | Self::Delete | Self::ManagePermission => RiskClass::R4, - Self::LegalConsent => RiskClass::R5, - } - } - - /// Return the capability required to request this action. - #[must_use] - pub const fn required_capability(self) -> Capability { - match self { - Self::Observe => Capability::Observe, - Self::Extract => Capability::Extract, - Self::Navigate => Capability::Navigate, - Self::Download => Capability::Download, - Self::Draft => Capability::Draft, - Self::Submit => Capability::Submit, - Self::Upload => Capability::Upload, - Self::FillSecret => Capability::FillSecret, - Self::Purchase => Capability::Purchase, - Self::Delete => Capability::Delete, - Self::ManagePermission => Capability::ManagePermission, - Self::LegalConsent => Capability::LegalConsent, - } - } - - /// Return whether execution can mutate browser or remote state. - #[must_use] - pub const fn mutates_state(self) -> bool { - !matches!( - self, - Self::Observe | Self::Extract | Self::Navigate | Self::Download - ) - } - - /// Return whether this action is designed to resolve a brokered secret. - #[must_use] - pub const fn uses_secret(self) -> bool { - matches!(self, Self::FillSecret) - } -} - -/// The exact action, target origin, and complete intent covered by an approval. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ApprovalScope { - action: ActionKind, - target_origin: Origin, - intent_digest: ActionIntentDigest, -} - -impl ApprovalScope { - /// Create one exact approval scope. - #[must_use] - pub const fn new( - action: ActionKind, - target_origin: Origin, - intent_digest: ActionIntentDigest, - ) -> Self { - Self { - action, - target_origin, - intent_digest, - } - } - - /// Return the approved action kind. - #[must_use] - pub const fn action(&self) -> ActionKind { - self.action - } - - /// Return the approved target origin. - #[must_use] - pub const fn target_origin(&self) -> &Origin { - &self.target_origin - } - - /// Return the approved complete-intent digest. - #[must_use] - pub const fn intent_digest(&self) -> &ActionIntentDigest { - &self.intent_digest - } -} - -/// Evidence that a high-risk action was approved for an exact scope. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ApprovalEvidence { - /// No approval was supplied. - None, - /// A person confirmed the exact action, target, and complete intent. - UserConfirmed(ApprovalScope), - /// A managed policy approved the exact action, target, and complete intent. - EnterprisePolicy(ApprovalScope), -} - -impl ApprovalEvidence { - /// Return whether this evidence authorizes the exact required scope. - #[must_use] - pub fn authorizes(&self, required: &ApprovalScope) -> bool { - match self { - Self::None => false, - Self::UserConfirmed(scope) | Self::EnterprisePolicy(scope) => scope == required, - } - } -} - -/// A complete typed request presented to the policy engine. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ActionRequest { - action: ActionKind, - source_origin: Origin, - target_origin: Origin, - instruction_source: InstructionSource, - secret_delivery: SecretDelivery, - intent_digest: ActionIntentDigest, -} - -impl ActionRequest { - /// Create one action request without executing it. - #[must_use] - pub const fn new( - action: ActionKind, - source_origin: Origin, - target_origin: Origin, - instruction_source: InstructionSource, - secret_delivery: SecretDelivery, - intent_digest: ActionIntentDigest, - ) -> Self { - Self { - action, - source_origin, - target_origin, - instruction_source, - secret_delivery, - intent_digest, - } - } - - /// Return the requested action. - #[must_use] - pub const fn action(&self) -> ActionKind { - self.action - } - - /// Return the origin that currently owns the browser context. - #[must_use] - pub const fn source_origin(&self) -> &Origin { - &self.source_origin - } - - /// Return the origin affected by the action. - #[must_use] - pub const fn target_origin(&self) -> &Origin { - &self.target_origin - } - - /// Return the trust class of the proposing instruction. - #[must_use] - pub const fn instruction_source(&self) -> InstructionSource { - self.instruction_source - } - - /// Return how secret material would be delivered. - #[must_use] - pub const fn secret_delivery(&self) -> SecretDelivery { - self.secret_delivery - } - - /// Return the digest of the complete canonical action intent. - #[must_use] - pub const fn intent_digest(&self) -> &ActionIntentDigest { - &self.intent_digest - } -} - -/// Immutable grants and mutable evidence used for one policy decision. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PolicyContext { - mode: SessionMode, - purpose: ExecutionPurpose, - capabilities: BTreeSet, - read_origins: BTreeSet, - write_origins: BTreeSet, - robots_decision: RobotsDecision, - approval: ApprovalEvidence, -} - -impl PolicyContext { - /// Create one policy context from explicitly granted capabilities and origins. - #[must_use] - pub const fn new( - mode: SessionMode, - purpose: ExecutionPurpose, - capabilities: BTreeSet, - read_origins: BTreeSet, - write_origins: BTreeSet, - robots_decision: RobotsDecision, - approval: ApprovalEvidence, - ) -> Self { - Self { - mode, - purpose, - capabilities, - read_origins, - write_origins, - robots_decision, - approval, - } - } - - /// Return the browser execution mode. - #[must_use] - pub const fn mode(&self) -> SessionMode { - self.mode - } - - /// Return the declared execution purpose. - #[must_use] - pub const fn purpose(&self) -> ExecutionPurpose { - self.purpose - } - - /// Return the granted capabilities. - #[must_use] - pub const fn capabilities(&self) -> &BTreeSet { - &self.capabilities - } - - /// Return the origins that may be read. - #[must_use] - pub const fn read_origins(&self) -> &BTreeSet { - &self.read_origins - } - - /// Return the origins that may be mutated. - #[must_use] - pub const fn write_origins(&self) -> &BTreeSet { - &self.write_origins - } - - /// Return the robots-exclusion decision. - #[must_use] - pub const fn robots_decision(&self) -> RobotsDecision { - self.robots_decision - } - - /// Replace robots evidence after a fresh policy lookup. - pub const fn set_robots_decision(&mut self, decision: RobotsDecision) { - self.robots_decision = decision; - } - - /// Return the supplied approval evidence. - #[must_use] - pub const fn approval(&self) -> &ApprovalEvidence { - &self.approval - } - - /// Replace approval evidence after a user or enterprise decision. - pub fn set_approval(&mut self, approval: ApprovalEvidence) { - self.approval = approval; - } -} - -/// A canonical Chromium extension identifier admitted to OriginWeave policy. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct ExtensionId { - canonical: String, -} - -impl ExtensionId { - /// Parse one canonical 32-character lowercase Chromium extension identifier. - /// - /// Chromium extension identifiers use only the lowercase `a` through `p` - /// alphabet. OriginWeave rejects any non-canonical spelling rather than - /// normalizing caller-controlled identity text. - pub fn parse(input: &str) -> Result { - if input.len() != 32 { - return Err(ExtensionIdError::InvalidExtensionId); - } - if !input.bytes().all(|byte| (b'a'..=b'p').contains(&byte)) { - return Err(ExtensionIdError::InvalidExtensionId); - } - Ok(Self { - canonical: input.to_owned(), - }) - } - - /// Return the canonical extension identifier. - #[must_use] - pub fn as_str(&self) -> &str { - &self.canonical - } -} - -/// A validation error for a Chromium extension identifier. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExtensionIdError { - /// The value was not exactly 32 lowercase characters from `a` through `p`. - InvalidExtensionId, -} - -/// An OriginWeave Agent capability that a browser extension may request explicitly. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ExtensionAgentCapability { - /// Observe the governed semantic representation of the exact current context. - ObserveCurrentContext, - /// Propose a typed action for independent OriginWeave policy evaluation. - ProposeTypedAction, -} - -/// An explicit host-originated grant from one extension to bounded Agent capabilities. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExtensionAgentGrant { - extension_id: ExtensionId, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: Origin, - expires_at_epoch_seconds: u64, - capabilities: BTreeSet, -} - -impl ExtensionAgentGrant { - /// Build an exact extension-to-Agent grant for one session, context, origin, and exclusive expiry. - #[must_use] - pub fn new( - extension_id: ExtensionId, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: Origin, - expires_at_epoch_seconds: u64, - capabilities: I, - ) -> Self - where - I: IntoIterator, - { - Self { - extension_id, - browser_session, - browsing_context, - origin, - expires_at_epoch_seconds, - capabilities: capabilities.into_iter().collect(), - } - } -} - -/// One extension request to use a bounded OriginWeave Agent capability. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExtensionAccessRequest { - extension_id: ExtensionId, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: Origin, - now_epoch_seconds: u64, - capability: ExtensionAgentCapability, -} - -impl ExtensionAccessRequest { - /// Build one exact extension capability request without granting authority. - /// - /// `now_epoch_seconds` must be trusted evaluation time supplied by the host, - /// not a page, extension, or model clock. - #[must_use] - pub const fn new( - extension_id: ExtensionId, - browser_session: BrowserSessionId, - browsing_context: BrowsingContextId, - origin: Origin, - now_epoch_seconds: u64, - capability: ExtensionAgentCapability, - ) -> Self { - Self { - extension_id, - browser_session, - browsing_context, - origin, - now_epoch_seconds, - capability, - } - } -} - -/// Result of evaluating an extension request against one explicit Agent grant. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExtensionAccessDecision { - /// The exact extension, session, context, origin, unexpired grant, and capability are explicitly granted. - Allow, - /// No explicit extension-to-Agent grant was supplied. - DenyMissingGrant, - /// The request belongs to a different extension identity. - DenyExtensionMismatch, - /// The request belongs to a different browser automation session. - DenyBrowserSessionMismatch, - /// The request belongs to a different independently navigable browser context. - DenyBrowsingContextMismatch, - /// The request belongs to a different canonical origin than the grant. - DenyOriginMismatch, - /// Trusted evaluation time is at or after the grant's exclusive expiry. - DenyExpired, - /// The extension grant does not contain the requested OriginWeave capability. - DenyCapabilityNotGranted, -} - -/// Evaluate extension Agent access without inheriting ambient Chrome permissions. -/// -/// A Chrome extension permission, installation state, or page capability is never -/// consulted here. A future Chromium adapter must construct a host-originated -/// [`ExtensionAgentGrant`] explicitly and re-evaluate the exact session, context, -/// canonical origin, and exclusive expiry at the boundary where Agent authority -/// would otherwise cross. -#[must_use] -pub fn evaluate_extension_access( - request: &ExtensionAccessRequest, - grant: Option<&ExtensionAgentGrant>, -) -> ExtensionAccessDecision { - let Some(grant) = grant else { - return ExtensionAccessDecision::DenyMissingGrant; - }; - if request.extension_id != grant.extension_id { - return ExtensionAccessDecision::DenyExtensionMismatch; - } - if request.browser_session != grant.browser_session { - return ExtensionAccessDecision::DenyBrowserSessionMismatch; - } - if request.browsing_context != grant.browsing_context { - return ExtensionAccessDecision::DenyBrowsingContextMismatch; - } - if request.origin != grant.origin { - return ExtensionAccessDecision::DenyOriginMismatch; - } - if request.now_epoch_seconds >= grant.expires_at_epoch_seconds { - return ExtensionAccessDecision::DenyExpired; - } - if !grant.capabilities.contains(&request.capability) { - return ExtensionAccessDecision::DenyCapabilityNotGranted; - } - ExtensionAccessDecision::Allow -} +mod browser_authority_registry; +mod browser_protocol; +mod browser_protocol_dispatch; +mod browser_protocol_operation; +mod browser_registry; +#[cfg(test)] +mod browser_registry_coverage; +mod contracts; +mod webdriver_bidi_command; +mod webdriver_bidi_error_code; +mod webdriver_bidi_response_document; +mod webdriver_bidi_response_document_correlation; +mod webdriver_bidi_response_envelope; +mod webdriver_bidi_result; +mod webdriver_bidi_websocket_connect_target; +mod webdriver_bidi_websocket_endpoint; + +pub use browser_authority_registry::BrowserAuthorityRegistry; +pub use browser_protocol::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, + BrowserProtocolRuntimeRequirementError, BrowserProtocolUseValidationError, + BrowserProtocolVersionRequirementError, MAX_BROWSER_PROTOCOL_METADATA_BYTES, + OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError, ValidatedBrowserProtocolUse, +}; +pub use browser_protocol_dispatch::{ + BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolRuntimeMetadata, +}; +pub use browser_protocol_operation::{ + BrowserProtocolOperation, MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, + MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, + WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE, + WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, + WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiQueryNodesAdmissionError, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiRemoteNodeReferenceError, +}; +pub(crate) use browser_registry::contains_disallowed_protocol_text; +pub use browser_registry::{ + BrowserRegistryError, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, + UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, +}; +pub use contracts::*; +pub use webdriver_bidi_command::{ + CorrelatedWebDriverBiDiLocateNodesResponse, MAX_WEBDRIVER_BIDI_COMMAND_ID, + ValidatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiCommandResponseKind, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesCommandError, + WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseEnvelopeError, +}; +pub use webdriver_bidi_error_code::WebDriverBiDiErrorCode; +pub use webdriver_bidi_response_document::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, + WebDriverBiDiResponseDocumentAdmissionError, +}; +pub use webdriver_bidi_response_document_correlation::WebDriverBiDiLocateNodesResponseDocumentError; +pub use webdriver_bidi_response_envelope::{ + MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH, MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS, + ParsedWebDriverBiDiCommandResponseEnvelope, WebDriverBiDiResponseEnvelopeParseError, +}; +pub use webdriver_bidi_result::{ + ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, +}; +pub use webdriver_bidi_websocket_connect_target::{ + VerifiedWebDriverBiDiSocketPeer, WebDriverBiDiSocketPeerVerificationError, + WebDriverBiDiWebSocketConnectTarget, WebDriverBiDiWebSocketConnectTargetError, +}; +pub use webdriver_bidi_websocket_endpoint::{ + CorrelatedWebDriverBiDiWebSocketEndpoint, MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES, + WebDriverBiDiWebSocketEndpoint, WebDriverBiDiWebSocketEndpointAdmissionError, + WebDriverBiDiWebSocketEndpointCorrelationError, +}; diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs deleted file mode 100644 index c7200e327..000000000 --- a/crates/originweave-core/src/mcp.rs +++ /dev/null @@ -1,478 +0,0 @@ -//! Fail-closed MCP routing integrity for the external adapter boundary. -//! -//! This module validates only the stateless MCP protocol/method/tool routing -//! envelope and derives an existing [`ActionKind`]. It is deliberately not an -//! authorization decision: callers must independently enforce OriginWeave -//! capability, risk, approval, origin, secret-broker, and evidence policies. -//! No MCP arguments, outputs, credentials, or arbitrary model-visible values -//! are retained by this boundary. - -use std::fmt; - -use crate::{ActionKind, Capability, RiskClass}; - -/// MCP protocol generation accepted by this stateless adapter boundary. -pub const MCP_PROTOCOL_VERSION: &str = "2026-07-28"; - -/// The only MCP method that can enter the typed action-routing boundary. -pub const MCP_TOOLS_CALL_METHOD: &str = "tools/call"; - -/// The MCP discovery method accepted by the typed tools-list boundary. -pub const MCP_TOOLS_LIST_METHOD: &str = "tools/list"; - -/// Maximum accepted MCP method-name length in bytes. -pub const MAX_MCP_METHOD_NAME_BYTES: usize = 64; - -/// Maximum accepted MCP tool-name length in bytes. -pub const MAX_MCP_TOOL_NAME_BYTES: usize = 128; - -/// One deterministic MCP tool descriptor derived from OriginWeave's reviewed action registry. -/// -/// The descriptor is discovery metadata only. It does not grant capabilities, origin access, -/// approval, secret access, or any other authority. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct McpToolCatalogEntry { - tool_name: &'static str, - action_kind: ActionKind, -} - -impl McpToolCatalogEntry { - /// Return the canonical MCP tool name exposed by this registry entry. - #[must_use] - pub const fn tool_name(&self) -> &'static str { - self.tool_name - } - - /// Return the typed OriginWeave action represented by this registry entry. - #[must_use] - pub const fn action_kind(&self) -> ActionKind { - self.action_kind - } - - /// Return the capability required by the represented action. - #[must_use] - pub const fn required_capability(&self) -> Capability { - self.action_kind.required_capability() - } - - /// Return the risk class assigned to the represented action. - #[must_use] - pub const fn risk_class(&self) -> RiskClass { - self.action_kind.risk_class() - } -} - -/// The complete explicit MCP tool-to-action registry accepted by this boundary. -/// -/// Order is deterministic so adapters can derive stable discovery output from this single -/// reviewed registry rather than maintaining a second mapping that could drift from routing. -const MCP_TOOL_CATALOG: &[McpToolCatalogEntry] = &[ - McpToolCatalogEntry { - tool_name: "originweave.observe", - action_kind: ActionKind::Observe, - }, - McpToolCatalogEntry { - tool_name: "originweave.extract", - action_kind: ActionKind::Extract, - }, - McpToolCatalogEntry { - tool_name: "originweave.navigate", - action_kind: ActionKind::Navigate, - }, - McpToolCatalogEntry { - tool_name: "originweave.download", - action_kind: ActionKind::Download, - }, - McpToolCatalogEntry { - tool_name: "originweave.draft", - action_kind: ActionKind::Draft, - }, - McpToolCatalogEntry { - tool_name: "originweave.submit", - action_kind: ActionKind::Submit, - }, - McpToolCatalogEntry { - tool_name: "originweave.upload", - action_kind: ActionKind::Upload, - }, - McpToolCatalogEntry { - tool_name: "originweave.fill_secret", - action_kind: ActionKind::FillSecret, - }, - McpToolCatalogEntry { - tool_name: "originweave.purchase", - action_kind: ActionKind::Purchase, - }, - McpToolCatalogEntry { - tool_name: "originweave.delete", - action_kind: ActionKind::Delete, - }, - McpToolCatalogEntry { - tool_name: "originweave.manage_permission", - action_kind: ActionKind::ManagePermission, - }, -]; - -/// Return the deterministic reviewed MCP tool catalog. -/// -/// Adapters may use this slice to derive discovery responses. Serialization, pagination, cache -/// policy, transport I/O, and authorization remain outside this stateless registry boundary. -#[must_use] -pub const fn supported_mcp_tools() -> &'static [McpToolCatalogEntry] { - MCP_TOOL_CATALOG -} - -/// Protocol disposition carried by a typed MCP result. -/// -/// OriginWeave currently constructs only terminal results at this boundary. A transport adapter -/// must serialize [`Self::Complete`] as MCP's `"complete"` result type and must not omit or -/// reinterpret the required protocol field. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum McpResultType { - /// The request completed and this value contains the final result. - Complete, -} - -/// Cache-sharing scope for an MCP cacheable list result. -/// -/// OriginWeave currently exposes only the conservative private scope. A transport adapter must -/// serialize this as MCP's `"private"` cache scope and must not widen it without a separately -/// reviewed policy that proves the returned catalog is safe to share across callers. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum McpCacheScope { - /// The result may be cached only for the current caller's private context. - Private, -} - -/// One typed MCP `tools/list` page derived from the reviewed tool catalog. -/// -/// This value is discovery metadata only. It does not grant any tool capability or action -/// authority. The initial contract is deliberately one complete private page with zero freshness -/// so adapters cannot omit MCP's required result disposition or accidentally share or reuse -/// discovery metadata beyond the current request. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct McpToolsListPage { - result_type: McpResultType, - tools: &'static [McpToolCatalogEntry], - ttl_ms: u64, - cache_scope: McpCacheScope, - next_cursor: Option<&'static str>, -} - -impl McpToolsListPage { - /// Return the mandatory MCP result disposition for this list page. - #[must_use] - pub const fn result_type(&self) -> McpResultType { - self.result_type - } - - /// Return the deterministic reviewed tool entries in this page. - #[must_use] - pub const fn tools(&self) -> &'static [McpToolCatalogEntry] { - self.tools - } - - /// Return the MCP freshness lifetime in milliseconds. - /// - /// The current conservative contract is zero, so clients must treat the result as - /// immediately stale rather than reusing it for a later request. - #[must_use] - pub const fn ttl_ms(&self) -> u64 { - self.ttl_ms - } - - /// Return the MCP cache-sharing scope for this page. - #[must_use] - pub const fn cache_scope(&self) -> McpCacheScope { - self.cache_scope - } - - /// Return the opaque continuation cursor when another page exists. - /// - /// The current fixed catalog is emitted as one complete page, so this is always `None`. - #[must_use] - pub const fn next_cursor(&self) -> Option<&'static str> { - self.next_cursor - } -} - -/// Build the conservative typed MCP `tools/list` result for the reviewed catalog. -/// -/// This function does not perform transport serialization, authorization, or pagination. It -/// binds the catalog to the mandatory complete result disposition plus explicit zero-TTL/private -/// cache hints so adapters cannot invent broader protocol or cache semantics independently from -/// this reviewed boundary. -#[must_use] -pub const fn mcp_tools_list_page() -> McpToolsListPage { - McpToolsListPage { - result_type: McpResultType::Complete, - tools: MCP_TOOL_CATALOG, - ttl_ms: 0, - cache_scope: McpCacheScope::Private, - next_cursor: None, - } -} - -/// A deterministic failure while validating one MCP `tools/list` request envelope. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum McpToolsListBoundaryError { - /// The transport request omitted the required MCP protocol-version header. - MissingProtocolVersionHeader, - /// The structured request metadata omitted the required MCP protocol version. - MissingProtocolVersionMetadata, - /// The transport protocol version disagrees with the structured request metadata. - ProtocolVersionHeaderBodyMismatch, - /// The request names an MCP protocol generation this boundary does not support. - UnsupportedProtocolVersion, - /// The structured request metadata omitted the required client-capabilities object. - MissingClientCapabilities, - /// The request method violates the bounded ASCII MCP routing syntax. - InvalidMethod, - /// MCP routing method metadata disagrees with the method in the request body. - MethodHeaderBodyMismatch, - /// The request method is not the supported `tools/list` operation. - UnsupportedMethod, - /// The request supplied a cursor that this fixed single-page catalog never issued. - UnsupportedCursor, -} - -impl fmt::Display for McpToolsListBoundaryError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::MissingProtocolVersionHeader => { - formatter.write_str("MCP protocol version header is required") - } - Self::MissingProtocolVersionMetadata => { - formatter.write_str("MCP request metadata protocol version is required") - } - Self::ProtocolVersionHeaderBodyMismatch => { - formatter.write_str("MCP protocol version header does not match request metadata") - } - Self::UnsupportedProtocolVersion => { - formatter.write_str("unsupported MCP protocol version") - } - Self::MissingClientCapabilities => { - formatter.write_str("MCP request metadata client capabilities are required") - } - Self::InvalidMethod => { - formatter.write_str("MCP method violates the bounded ASCII routing syntax") - } - Self::MethodHeaderBodyMismatch => { - formatter.write_str("MCP method header does not match the request body") - } - Self::UnsupportedMethod => { - formatter.write_str("only MCP tools/list requests can enter the discovery boundary") - } - Self::UnsupportedCursor => { - formatter.write_str("MCP tools/list cursor was not issued by this fixed catalog") - } - } - } -} - -impl std::error::Error for McpToolsListBoundaryError {} - -/// An MCP `tools/list` request whose protocol, required metadata, and routing envelope were -/// validated. -/// -/// This boundary is deliberately narrower than a general transport or pagination implementation. -/// A trusted structured parser must prove whether the required per-request client-capabilities -/// object was present; this type never accepts its contents as authority. The current reviewed -/// catalog returns one complete page and emits no continuation cursor, so no non-null cursor can -/// be a value previously issued by OriginWeave. A transport adapter must not silently ignore or -/// reinterpret a supplied cursor. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ValidatedMcpToolsListRequest { - method: &'static str, -} - -impl ValidatedMcpToolsListRequest { - /// Validate the stateless request envelope for the current fixed `tools/list` catalog. - /// - /// Both the required transport protocol-version header and structured request `_meta` - /// protocol version must be present, individually bounded to the exact supported-version - /// length before cross-field comparison, equal, and exactly [`MCP_PROTOCOL_VERSION`]. A - /// trusted structured parser must also attest that the required `_meta` client-capabilities - /// object was present; its contents grant no OriginWeave authority. Each untrusted method - /// value is shape-validated before comparison. The routing/body method must then agree exactly. - /// Any supplied cursor fails closed because [`mcp_tools_list_page`] emits no continuation - /// cursor; accepting one would silently invent pagination state that OriginWeave never issued. - pub fn new( - protocol_version_header: Option<&str>, - protocol_version_metadata: Option<&str>, - client_capabilities_present: bool, - routing_method: &str, - body_method: &str, - cursor: Option<&str>, - ) -> Result { - let protocol_version_header = protocol_version_header - .ok_or(McpToolsListBoundaryError::MissingProtocolVersionHeader)?; - let protocol_version_metadata = protocol_version_metadata - .ok_or(McpToolsListBoundaryError::MissingProtocolVersionMetadata)?; - - if protocol_version_header.len() > MCP_PROTOCOL_VERSION.len() - || protocol_version_metadata.len() > MCP_PROTOCOL_VERSION.len() - { - return Err(McpToolsListBoundaryError::UnsupportedProtocolVersion); - } - if protocol_version_header != protocol_version_metadata { - return Err(McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch); - } - if protocol_version_metadata != MCP_PROTOCOL_VERSION { - return Err(McpToolsListBoundaryError::UnsupportedProtocolVersion); - } - if !client_capabilities_present { - return Err(McpToolsListBoundaryError::MissingClientCapabilities); - } - if !valid_method(routing_method) || !valid_method(body_method) { - return Err(McpToolsListBoundaryError::InvalidMethod); - } - if routing_method != body_method { - return Err(McpToolsListBoundaryError::MethodHeaderBodyMismatch); - } - if routing_method != MCP_TOOLS_LIST_METHOD { - return Err(McpToolsListBoundaryError::UnsupportedMethod); - } - if cursor.is_some() { - return Err(McpToolsListBoundaryError::UnsupportedCursor); - } - - Ok(Self { - method: MCP_TOOLS_LIST_METHOD, - }) - } - - /// Return the canonical MCP method validated by this request. - #[must_use] - pub const fn method(&self) -> &'static str { - self.method - } -} - -/// A deterministic failure while validating untrusted MCP routing metadata. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum McpToolBoundaryError { - /// The request names an MCP protocol generation this boundary does not support. - UnsupportedProtocolVersion, - /// MCP routing metadata disagrees with the method or tool name in the body. - HeaderBodyMismatch, - /// The request method violates the bounded ASCII MCP routing syntax. - InvalidMethod, - /// The request method is not the supported `tools/call` operation. - UnsupportedMethod, - /// The tool name violates the bounded ASCII MCP routing syntax. - InvalidToolName, - /// The tool name has no explicit mapping to an OriginWeave typed action. - UnknownTool, -} - -impl fmt::Display for McpToolBoundaryError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::UnsupportedProtocolVersion => { - formatter.write_str("unsupported MCP protocol version") - } - Self::HeaderBodyMismatch => { - formatter.write_str("MCP routing headers do not match the request body") - } - Self::InvalidMethod => { - formatter.write_str("MCP method violates the bounded ASCII routing syntax") - } - Self::UnsupportedMethod => formatter - .write_str("only MCP tools/call requests can enter the typed action boundary"), - Self::InvalidToolName => { - formatter.write_str("MCP tool name violates the bounded ASCII routing syntax") - } - Self::UnknownTool => { - formatter.write_str("MCP tool is not mapped to an OriginWeave typed action") - } - } - } -} - -impl std::error::Error for McpToolBoundaryError {} - -/// An MCP tool call whose routing envelope has been validated and mapped. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ValidatedMcpToolCall { - tool_name: &'static str, - action_kind: ActionKind, -} - -impl ValidatedMcpToolCall { - /// Validate one stateless MCP tool-call routing envelope. - /// - /// Routing integrity is intentionally narrower than authorization. A - /// successful value proves only that the untrusted protocol version, - /// routing metadata, body method, and body tool name agree with one - /// explicitly supported mapping. Each untrusted method and tool name is - /// shape-validated before cross-field comparison so malformed or oversized - /// metadata cannot bypass the bounded routing syntax through mismatch handling. - pub fn new( - protocol_version: &str, - routing_method: &str, - routing_tool_name: &str, - body_method: &str, - body_tool_name: &str, - ) -> Result { - if protocol_version != MCP_PROTOCOL_VERSION { - return Err(McpToolBoundaryError::UnsupportedProtocolVersion); - } - if !valid_method(routing_method) || !valid_method(body_method) { - return Err(McpToolBoundaryError::InvalidMethod); - } - if !valid_tool_name(routing_tool_name) || !valid_tool_name(body_tool_name) { - return Err(McpToolBoundaryError::InvalidToolName); - } - if routing_method != body_method || routing_tool_name != body_tool_name { - return Err(McpToolBoundaryError::HeaderBodyMismatch); - } - if routing_method != MCP_TOOLS_CALL_METHOD { - return Err(McpToolBoundaryError::UnsupportedMethod); - } - - let (tool_name, action_kind) = map_tool(routing_tool_name)?; - Ok(Self { - tool_name, - action_kind, - }) - } - - /// Return the canonical static tool name selected by the explicit mapping. - #[must_use] - pub const fn tool_name(&self) -> &'static str { - self.tool_name - } - - /// Return the existing OriginWeave typed action selected by this tool. - #[must_use] - pub const fn action_kind(&self) -> ActionKind { - self.action_kind - } -} - -fn valid_method(method: &str) -> bool { - if method.is_empty() || method.len() > MAX_MCP_METHOD_NAME_BYTES { - return false; - } - method - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/')) -} - -fn valid_tool_name(tool_name: &str) -> bool { - if tool_name.is_empty() || tool_name.len() > MAX_MCP_TOOL_NAME_BYTES { - return false; - } - tool_name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) -} - -fn map_tool(tool_name: &str) -> Result<(&'static str, ActionKind), McpToolBoundaryError> { - MCP_TOOL_CATALOG - .iter() - .find(|entry| entry.tool_name == tool_name) - .map(|entry| (entry.tool_name, entry.action_kind)) - .ok_or(McpToolBoundaryError::UnknownTool) -} diff --git a/crates/originweave-core/src/release_acceptance.rs b/crates/originweave-core/src/release_acceptance.rs deleted file mode 100644 index a3655de52..000000000 --- a/crates/originweave-core/src/release_acceptance.rs +++ /dev/null @@ -1,368 +0,0 @@ -//! Deterministic fail-closed release acceptance for commercial benchmark evidence. -//! -//! This module aggregates only explicit mandatory-suite outcomes and bounded, -//! buyer-visible limitations. It does not execute benchmarks, infer missing -//! evidence, authenticate artifacts, or grant release authority. - -use std::fmt; - -use unicode_normalization::is_nfc; - -/// Maximum UTF-8 byte length retained for either buyer-visible limitation field. -pub const MAX_RELEASE_LIMITATION_TEXT_BYTES: usize = 1024; - -/// Maximum number of buyer-visible limitations retained in one release report. -pub const MAX_DECLARED_RELEASE_LIMITATIONS: usize = 64; - -/// One mandatory benchmark suite in the release acceptance contract. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum BenchmarkSuite { - /// Controlled local fixtures with deterministic post-condition oracles. - ControlledDeterministic, - /// Stable web compatibility tasks for the declared support profile. - WebCompatibility, - /// Hostile security cases that measure unauthorized authority or disclosure. - SecurityAdversarial, - /// Crash, timeout, retry, reconciliation, cleanup, and restore behavior. - ReliabilityRecovery, - /// Enterprise isolation, identity, policy, audit, and operator controls. - EnterpriseOperability, -} - -impl BenchmarkSuite { - /// Every mandatory benchmark suite in canonical release-report order. - pub const ALL: [Self; 5] = [ - Self::ControlledDeterministic, - Self::WebCompatibility, - Self::SecurityAdversarial, - Self::ReliabilityRecovery, - Self::EnterpriseOperability, - ]; - - /// Return the stable snake-case suite identifier used by benchmark evidence. - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::ControlledDeterministic => "controlled_deterministic_suite", - Self::WebCompatibility => "web_compatibility_suite", - Self::SecurityAdversarial => "security_adversarial_suite", - Self::ReliabilityRecovery => "reliability_recovery_suite", - Self::EnterpriseOperability => "enterprise_operability_suite", - } - } - - const fn index(self) -> usize { - match self { - Self::ControlledDeterministic => 0, - Self::WebCompatibility => 1, - Self::SecurityAdversarial => 2, - Self::ReliabilityRecovery => 3, - Self::EnterpriseOperability => 4, - } - } -} - -/// Evaluated outcome for one mandatory benchmark suite. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BenchmarkSuiteOutcome { - /// Every threshold required for the declared profile passed. - Passed, - /// At least one mandatory threshold is known to have failed. - Failed, - /// Evidence is insufficient to establish either pass or threshold failure. - Inconclusive, -} - -/// One explicit narrowed release claim and its buyer-visible consequence. -/// -/// An accepted-with-limitations decision cannot be produced from an opaque -/// boolean. Every limitation must name the unsupported claim and state the -/// consequence that a buyer must account for in the declared support profile. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DeclaredLimitation { - unsupported_claim: String, - buyer_consequence: String, -} - -impl DeclaredLimitation { - /// Construct one explicit buyer-visible release limitation. - /// - /// Empty/whitespace-only or punctuation-only values, surrounding whitespace, - /// non-NFC Unicode, fields exceeding the fixed UTF-8 byte budget, and ambiguous - /// presentation characters fail closed because they cannot safely represent one - /// canonical, resource-bounded buyer-visible release limitation. Accepted text - /// is retained byte-for-byte; this constructor never normalizes caller input - /// implicitly. - pub fn new( - unsupported_claim: impl Into, - buyer_consequence: impl Into, - ) -> Result { - Self::from_owned_text(unsupported_claim.into(), buyer_consequence.into()) - } - - fn from_owned_text( - unsupported_claim: String, - buyer_consequence: String, - ) -> Result { - if unsupported_claim.trim().is_empty() { - return Err(ReleaseDecisionError::EmptyLimitationClaim); - } - if unsupported_claim.trim() != unsupported_claim { - return Err(ReleaseDecisionError::InvalidLimitationClaim); - } - if unsupported_claim.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { - return Err(ReleaseDecisionError::LimitationClaimTooLong); - } - if !is_nfc(&unsupported_claim) { - return Err(ReleaseDecisionError::InvalidLimitationClaim); - } - if unsupported_claim - .chars() - .any(disallowed_release_limitation_character) - || !unsupported_claim.chars().any(char::is_alphanumeric) - { - return Err(ReleaseDecisionError::InvalidLimitationClaim); - } - if buyer_consequence.trim().is_empty() { - return Err(ReleaseDecisionError::EmptyLimitationConsequence); - } - if buyer_consequence.trim() != buyer_consequence { - return Err(ReleaseDecisionError::InvalidLimitationConsequence); - } - if buyer_consequence.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { - return Err(ReleaseDecisionError::LimitationConsequenceTooLong); - } - if !is_nfc(&buyer_consequence) { - return Err(ReleaseDecisionError::InvalidLimitationConsequence); - } - if buyer_consequence - .chars() - .any(disallowed_release_limitation_character) - || !buyer_consequence.chars().any(char::is_alphanumeric) - { - return Err(ReleaseDecisionError::InvalidLimitationConsequence); - } - Ok(Self { - unsupported_claim, - buyer_consequence, - }) - } - - /// Return the exact unsupported or narrowed release claim. - #[must_use] - pub fn unsupported_claim(&self) -> &str { - &self.unsupported_claim - } - - /// Return the exact consequence exposed to buyers and operators. - #[must_use] - pub fn buyer_consequence(&self) -> &str { - &self.buyer_consequence - } -} - -fn disallowed_release_limitation_character(character: char) -> bool { - let code_point = character as u32; - character.is_control() - || matches!( - code_point, - 0x00ad - | 0x034f - | 0x061c - | 0x115f..=0x1160 - | 0x17b4..=0x17b5 - | 0x180b..=0x180f - | 0x200b..=0x200f - | 0x2028..=0x202e - | 0x2060..=0x206f - | 0x3164 - | 0xfe00..=0xfe0f - | 0xfeff - | 0xffa0 - | 0xfff0..=0xfff8 - | 0x1bca0..=0x1bca3 - | 0x1d173..=0x1d17a - | 0xe0000..=0xe0fff - ) -} - -/// Deterministic release decision produced from mandatory suite evidence. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReleaseDecision { - /// Every mandatory suite passed for the full declared support profile. - Accepted, - /// Every mandatory suite passed after buyer-visible limitations were declared. - AcceptedWithDeclaredLimitations, - /// At least one mandatory suite is known to have failed its threshold. - Rejected, - /// No known threshold failure exists, but mandatory evidence is incomplete. - Inconclusive, -} - -/// Fail-closed input error while constructing a release decision. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReleaseDecisionError { - /// A declared limitation did not identify the unsupported release claim. - EmptyLimitationClaim, - /// A declared limitation claim exceeded the fixed UTF-8 byte budget. - LimitationClaimTooLong, - /// A declared limitation claim was not canonical NFC text or was presentation-unsafe. - InvalidLimitationClaim, - /// A declared limitation did not state the buyer-visible consequence. - EmptyLimitationConsequence, - /// A declared limitation consequence exceeded the fixed UTF-8 byte budget. - LimitationConsequenceTooLong, - /// A limitation consequence was not canonical NFC text or was presentation-unsafe. - InvalidLimitationConsequence, - /// One release report supplied more buyer-visible limitations than the fixed resource budget. - TooManyDeclaredLimitations, - /// More than one limitation used the same unsupported claim identity. - DuplicateLimitationClaim, - /// The same suite appeared more than once instead of one authoritative result. - DuplicateSuite(BenchmarkSuite), -} - -impl fmt::Display for ReleaseDecisionError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::EmptyLimitationClaim => { - formatter.write_str("declared release limitation must name an unsupported claim") - } - Self::LimitationClaimTooLong => { - formatter.write_str("declared release limitation claim exceeds the byte budget") - } - Self::InvalidLimitationClaim => formatter.write_str( - "declared release limitation claim is not canonical or contains an unsafe presentation character", - ), - Self::EmptyLimitationConsequence => formatter - .write_str("declared release limitation must state a buyer-visible consequence"), - Self::LimitationConsequenceTooLong => formatter - .write_str("declared release limitation consequence exceeds the byte budget"), - Self::InvalidLimitationConsequence => formatter.write_str( - "declared release limitation consequence is not canonical or contains an unsafe presentation character", - ), - Self::TooManyDeclaredLimitations => formatter - .write_str("benchmark release decision contains too many declared limitations"), - Self::DuplicateLimitationClaim => formatter - .write_str("benchmark release decision contains duplicate limitation claim"), - Self::DuplicateSuite(suite) => write!( - formatter, - "benchmark release evidence contains duplicate suite: {}", - suite.as_str() - ), - } - } -} - -impl std::error::Error for ReleaseDecisionError {} - -/// Release decision together with exact mandatory-suite evidence gaps and failures. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReleaseDecisionReport { - decision: ReleaseDecision, - failed_suites: Vec, - inconclusive_suites: Vec, - missing_suites: Vec, - declared_limitations: Vec, -} - -impl ReleaseDecisionReport { - /// Return the deterministic release decision. - #[must_use] - pub const fn decision(&self) -> ReleaseDecision { - self.decision - } - - /// Return suites with a known mandatory-threshold failure. - #[must_use] - pub fn failed_suites(&self) -> &[BenchmarkSuite] { - &self.failed_suites - } - - /// Return suites whose supplied evidence was explicitly inconclusive. - #[must_use] - pub fn inconclusive_suites(&self) -> &[BenchmarkSuite] { - &self.inconclusive_suites - } - - /// Return mandatory suites for which no outcome was supplied. - #[must_use] - pub fn missing_suites(&self) -> &[BenchmarkSuite] { - &self.missing_suites - } - - /// Return the exact buyer-visible limitations retained with this decision. - #[must_use] - pub fn declared_limitations(&self) -> &[DeclaredLimitation] { - &self.declared_limitations - } -} - -/// Produce one deterministic release decision from mandatory suite outcomes. -/// -/// Duplicate suite evidence, duplicate buyer-visible limitation claim identities, -/// and excessive declared-limitation cardinality fail closed rather than selecting -/// or retaining ambiguous or attacker-controlled release metadata. A known -/// mandatory-threshold failure is always rejected, even when other suites are -/// missing or inconclusive; all such evidence gaps remain in the returned report. -/// Without a known failure, missing or inconclusive evidence is never promoted to -/// acceptance. Accepted-with-limitations requires at least one validated -/// [`DeclaredLimitation`], so the decision cannot be detached from the exact -/// narrowed claim and buyer-visible consequence. -pub fn decide_release( - results: I, - declared_limitations: &[DeclaredLimitation], -) -> Result -where - I: IntoIterator, -{ - if declared_limitations.len() > MAX_DECLARED_RELEASE_LIMITATIONS { - return Err(ReleaseDecisionError::TooManyDeclaredLimitations); - } - - let mut limitation_claims = std::collections::BTreeSet::new(); - for limitation in declared_limitations { - if !limitation_claims.insert(limitation.unsupported_claim()) { - return Err(ReleaseDecisionError::DuplicateLimitationClaim); - } - } - - let mut outcomes = [None; BenchmarkSuite::ALL.len()]; - for (suite, outcome) in results { - let slot = &mut outcomes[suite.index()]; - if slot.is_some() { - return Err(ReleaseDecisionError::DuplicateSuite(suite)); - } - *slot = Some(outcome); - } - - let mut failed_suites = Vec::new(); - let mut inconclusive_suites = Vec::new(); - let mut missing_suites = Vec::new(); - for suite in BenchmarkSuite::ALL { - match outcomes[suite.index()] { - Some(BenchmarkSuiteOutcome::Passed) => {} - Some(BenchmarkSuiteOutcome::Failed) => failed_suites.push(suite), - Some(BenchmarkSuiteOutcome::Inconclusive) => inconclusive_suites.push(suite), - None => missing_suites.push(suite), - } - } - - let decision = if !failed_suites.is_empty() { - ReleaseDecision::Rejected - } else if !inconclusive_suites.is_empty() || !missing_suites.is_empty() { - ReleaseDecision::Inconclusive - } else if declared_limitations.is_empty() { - ReleaseDecision::Accepted - } else { - ReleaseDecision::AcceptedWithDeclaredLimitations - }; - - Ok(ReleaseDecisionReport { - decision, - failed_suites, - inconclusive_suites, - missing_suites, - declared_limitations: declared_limitations.to_vec(), - }) -} diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs deleted file mode 100644 index c47a136d4..000000000 --- a/crates/originweave-core/src/root.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Shared security and governance contracts for OriginWeave. -//! -//! The historical core contracts remain source-compatible while adapter-specific -//! boundaries can live in focused modules without changing their authority model. - -#![forbid(unsafe_code)] -#![deny(missing_docs)] - -#[path = "lib.rs"] -mod contracts; - -pub use contracts::*; - -/// Stateless MCP routing validation that maps only explicit tools to typed actions. -pub mod mcp; -/// Deterministic fail-closed release benchmark acceptance aggregation. -pub mod release_acceptance; diff --git a/crates/originweave-core/src/webdriver_bidi_command.rs b/crates/originweave-core/src/webdriver_bidi_command.rs new file mode 100644 index 000000000..9a019cc45 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_command.rs @@ -0,0 +1,410 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use crate::{ + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, + contains_disallowed_protocol_text, +}; + +/// Maximum WebDriver BiDi command identifier representable by the protocol `js-uint` type. +pub const MAX_WEBDRIVER_BIDI_COMMAND_ID: u64 = 9_007_199_254_740_991; + +/// Fail-closed validation errors for one serialized WebDriver BiDi `locateNodes` command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesCommandError { + /// The command identifier exceeds WebDriver BiDi's unsigned safe-integer range. + InvalidCommandId, + /// The browsing-context identifier is empty, over budget, or contains disallowed text. + InvalidBrowsingContext, +} + +impl Display for WebDriverBiDiLocateNodesCommandError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidCommandId => "WebDriver BiDi command id is outside the js-uint range", + Self::InvalidBrowsingContext => { + "WebDriver BiDi browsing context is empty, over budget, or contains disallowed text" + } + }) + } +} + +impl Error for WebDriverBiDiLocateNodesCommandError {} + +/// Fail-closed errors while correlating one WebDriver BiDi response with its exact command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesResponseCorrelationError { + /// The returned response identifier exceeds WebDriver BiDi's `js-uint` range. + InvalidResponseId, + /// The returned response identifier belongs to a different in-flight command. + ResponseIdMismatch { + /// Exact command identifier that this response must carry. + expected: u64, + /// Untrusted response identifier returned by the adapter. + actual: u64, + }, +} + +impl Display for WebDriverBiDiLocateNodesResponseCorrelationError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidResponseId => { + formatter.write_str("WebDriver BiDi response id is outside the js-uint range") + } + Self::ResponseIdMismatch { expected, actual } => write!( + formatter, + "WebDriver BiDi response id {actual} does not match command id {expected}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesResponseCorrelationError {} + +/// Structured WebDriver BiDi command-response envelope kind retained through correlation. +/// +/// A later trusted parser must derive this classification from the exact wire envelope. This value +/// does not validate raw JSON or grant browser, node, policy, or Agent authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiCommandResponseKind { + /// A WebDriver BiDi command success response. + Success, + /// A WebDriver BiDi command error response. + Error, +} + +/// Fail-closed errors while admitting a structured WebDriver BiDi response envelope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesResponseEnvelopeError { + /// A success envelope did not carry the required command response identifier. + MissingResponseId, + /// An error envelope carried no recoverable command identifier and cannot be correlated. + UncorrelatableErrorResponse, + /// A correlated error envelope cannot be converted into success response evidence. + CorrelatedErrorResponse, + /// The present response identifier failed exact command correlation. + Correlation(WebDriverBiDiLocateNodesResponseCorrelationError), +} + +impl Display for WebDriverBiDiLocateNodesResponseEnvelopeError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingResponseId => { + formatter.write_str("WebDriver BiDi success response is missing its command id") + } + Self::UncorrelatableErrorResponse => formatter.write_str( + "WebDriver BiDi error response has no recoverable command id for correlation", + ), + Self::CorrelatedErrorResponse => formatter + .write_str("WebDriver BiDi error response cannot become success response evidence"), + Self::Correlation(error) => write!( + formatter, + "WebDriver BiDi response envelope rejected command correlation: {error}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesResponseEnvelopeError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Correlation(error) => Some(error), + Self::MissingResponseId + | Self::UncorrelatableErrorResponse + | Self::CorrelatedErrorResponse => None, + } + } +} + +/// Non-cloneable evidence that one `locateNodes` response matched the exact command id. +/// +/// Only [`WebDriverBiDiLocateNodesCommand::correlate_response_id`] can construct this value. It +/// retains the exact command identifier, bounded browsing-context identifier, and exact serialized +/// result budget so a later trusted transport boundary can carry correlation evidence forward +/// without reconstructing authority from ambient query state. It does not authenticate a browser or +/// adapter, prove current OriginWeave session/context/origin authority, validate response payload +/// shape, admit nodes, or authorize an Agent action. +#[derive(Debug, PartialEq, Eq)] +pub struct ValidatedWebDriverBiDiLocateNodesResponse { + command_id: u64, + browsing_context: String, + max_node_count: u16, +} + +impl ValidatedWebDriverBiDiLocateNodesResponse { + /// Return the exact command identifier proven to match the response. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Return the bounded browsing-context identifier serialized by the matched command. + #[must_use] + pub fn browsing_context(&self) -> &str { + &self.browsing_context + } + + /// Return the exact `maxNodeCount` serialized by the matched command. + #[must_use] + pub const fn max_node_count(&self) -> u16 { + self.max_node_count + } + + /// Validate a parsed `locateNodes` result count against the matched command's exact budget. + /// + /// This check is intentionally carried by command-correlation evidence rather than by a + /// separately supplied query value, preventing downstream code from validating an untrusted + /// response against a different, more permissive result budget. Zero through the serialized + /// maximum are valid; any larger result fails closed before node normalization or admission. + pub fn validate_result_count( + &self, + returned_node_count: usize, + ) -> Result<(), WebDriverBiDiAccessibilityQueryError> { + if returned_node_count > usize::from(self.max_node_count) { + return Err(WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded); + } + Ok(()) + } +} + +/// Non-cloneable structured-envelope evidence for one correlated `locateNodes` response. +/// +/// This value deliberately keeps success and error envelopes distinguishable after exact response +/// id correlation. The only conversion into [`ValidatedWebDriverBiDiLocateNodesResponse`] is +/// [`Self::into_validated_success`], which fails closed for a correlated error envelope. A later +/// trusted response parser must classify the exact wire envelope before calling +/// [`WebDriverBiDiLocateNodesCommand::correlate_response_envelope`]. This value performs no raw JSON +/// parsing, browser or adapter authentication, node admission, policy authorization, or Agent +/// action authorization. +#[derive(Debug, PartialEq, Eq)] +pub struct CorrelatedWebDriverBiDiLocateNodesResponse { + kind: WebDriverBiDiCommandResponseKind, + correlated: ValidatedWebDriverBiDiLocateNodesResponse, +} + +impl CorrelatedWebDriverBiDiLocateNodesResponse { + /// Return whether the exact correlated envelope was classified as success or error. + #[must_use] + pub const fn kind(&self) -> WebDriverBiDiCommandResponseKind { + self.kind + } + + /// Return the exact command identifier proven to match the response. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.correlated.command_id() + } + + /// Return the bounded browsing-context identifier serialized by the matched command. + #[must_use] + pub fn browsing_context(&self) -> &str { + self.correlated.browsing_context() + } + + /// Consume this envelope and return correlation evidence only when it was a success response. + /// + /// A correlated WebDriver BiDi error envelope remains error evidence and is rejected as + /// [`WebDriverBiDiLocateNodesResponseEnvelopeError::CorrelatedErrorResponse`]. This explicit + /// fail-closed conversion prevents downstream result/node admission code from accidentally + /// erasing the protocol response kind while reusing exact command correlation evidence. + pub fn into_validated_success( + self, + ) -> Result< + ValidatedWebDriverBiDiLocateNodesResponse, + WebDriverBiDiLocateNodesResponseEnvelopeError, + > { + match self.kind { + WebDriverBiDiCommandResponseKind::Success => Ok(self.correlated), + WebDriverBiDiCommandResponseKind::Error => { + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::CorrelatedErrorResponse) + } + } + } +} + +/// Deterministic serialized command envelope for one bounded WebDriver BiDi accessibility query. +/// +/// Construction accepts only a WebDriver BiDi `js-uint` command identifier, a bounded opaque +/// browsing-context identifier, and an already validated [`WebDriverBiDiAccessibilityQuery`]. The +/// serialized envelope fixes the exact `browsingContext.locateNodes` method, accessibility locator, +/// finite node budget, and minimal serialization options carried by the query. String values are +/// JSON-escaped without interpreting their content. +/// +/// This is an inert transport value. It performs no browser I/O, authenticates no browser or +/// adapter, grants no session/context/origin authority, and cannot authorize policy or typed input. +/// A trusted transport adapter must still bind the command to the exact authenticated browser +/// session and later admit any response through the reviewed current-authority boundary. +#[derive(Debug, PartialEq, Eq)] +pub struct WebDriverBiDiLocateNodesCommand { + command_id: u64, + browsing_context: String, + max_node_count: u16, + json: String, +} + +impl WebDriverBiDiLocateNodesCommand { + /// Validate and serialize one bounded `browsingContext.locateNodes` command envelope. + pub fn new( + command_id: u64, + browsing_context: &str, + query: &WebDriverBiDiAccessibilityQuery, + ) -> Result { + if command_id > MAX_WEBDRIVER_BIDI_COMMAND_ID { + return Err(WebDriverBiDiLocateNodesCommandError::InvalidCommandId); + } + if browsing_context.is_empty() + || browsing_context.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + || contains_disallowed_protocol_text(browsing_context, false) + { + return Err(WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext); + } + + let mut json = String::from("{\"id\":"); + json.push_str(&command_id.to_string()); + json.push_str(",\"method\":\""); + json.push_str(WEBDRIVER_BIDI_LOCATE_NODES_METHOD); + json.push_str("\",\"params\":{\"context\":"); + push_json_string(&mut json, browsing_context); + json.push_str(",\"locator\":{\"type\":\""); + json.push_str(query.locator_type()); + json.push_str("\",\"value\":{"); + + if let Some(role) = query.role() { + json.push_str("\"role\":"); + push_json_string(&mut json, role); + } + if let Some(name) = query.name() { + if query.role().is_some() { + json.push(','); + } + json.push_str("\"name\":"); + push_json_string(&mut json, name); + } + + json.push_str("}},\"maxNodeCount\":"); + json.push_str(&query.max_node_count().to_string()); + json.push_str(",\"serializationOptions\":{\"maxDomDepth\":"); + json.push_str(&query.serialization_max_dom_depth().to_string()); + json.push_str(",\"maxObjectDepth\":"); + json.push_str(&query.serialization_max_object_depth().to_string()); + json.push_str(",\"includeShadowTree\":"); + push_json_string(&mut json, query.serialization_include_shadow_tree()); + json.push_str("}}}"); + + Ok(Self { + command_id, + browsing_context: browsing_context.to_owned(), + max_node_count: query.max_node_count(), + json, + }) + } + + /// Return the validated WebDriver BiDi command identifier. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.command_id + } + + /// Return the exact WebDriver BiDi method serialized by this command. + #[must_use] + pub const fn method(&self) -> &'static str { + WEBDRIVER_BIDI_LOCATE_NODES_METHOD + } + + /// Return the exact validated browsing-context identifier. + #[must_use] + pub fn browsing_context(&self) -> &str { + &self.browsing_context + } + + /// Return the deterministic JSON command envelope. + #[must_use] + pub fn as_json(&self) -> &str { + &self.json + } + + /// Consume this command and correlate one untrusted response identifier with it. + /// + /// The response identifier is validated against WebDriver BiDi's `js-uint` range before exact + /// equality is checked. Success consumes the command and returns non-cloneable correlation + /// evidence, preventing this command value from being reused to validate another response. The + /// evidence also retains the exact `maxNodeCount` serialized by this command so later result + /// admission cannot substitute a different query budget. This does not parse a response, + /// authenticate the transport, or grant browser/Agent authority. + pub fn correlate_response_id( + self, + response_id: u64, + ) -> Result< + ValidatedWebDriverBiDiLocateNodesResponse, + WebDriverBiDiLocateNodesResponseCorrelationError, + > { + if response_id > MAX_WEBDRIVER_BIDI_COMMAND_ID { + return Err(WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId); + } + if response_id != self.command_id { + return Err( + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: self.command_id, + actual: response_id, + }, + ); + } + + Ok(ValidatedWebDriverBiDiLocateNodesResponse { + command_id: self.command_id, + browsing_context: self.browsing_context, + max_node_count: self.max_node_count, + }) + } + + /// Consume this command and admit one already classified response envelope for correlation. + /// + /// A success envelope must carry a response id. A WebDriver BiDi error envelope may have a null + /// id when no valid command id can be recovered; that case returns + /// [`WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse`] and produces + /// no correlation evidence. When an id is present, the same protocol-range and exact-id checks + /// as [`Self::correlate_response_id`] apply. The returned evidence retains whether the envelope + /// was success or error so an error cannot silently become success evidence. + /// + /// The caller must obtain `kind` and `response_id` from a separately reviewed exact response + /// parser. This method does not parse JSON, validate result payload shape, authenticate a browser + /// or adapter, admit nodes, or grant policy, typed-input, secret, or Agent authority. + pub fn correlate_response_envelope( + self, + kind: WebDriverBiDiCommandResponseKind, + response_id: Option, + ) -> Result< + CorrelatedWebDriverBiDiLocateNodesResponse, + WebDriverBiDiLocateNodesResponseEnvelopeError, + > { + let response_id = match (kind, response_id) { + (WebDriverBiDiCommandResponseKind::Success, None) => { + return Err(WebDriverBiDiLocateNodesResponseEnvelopeError::MissingResponseId); + } + (WebDriverBiDiCommandResponseKind::Error, None) => { + return Err( + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + ); + } + (_, Some(response_id)) => response_id, + }; + let correlated = self + .correlate_response_id(response_id) + .map_err(WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation)?; + + Ok(CorrelatedWebDriverBiDiLocateNodesResponse { kind, correlated }) + } +} + +fn push_json_string(output: &mut String, value: &str) { + output.push('"'); + for character in value.chars() { + match character { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + character => output.push(character), + } + } + output.push('"'); +} diff --git a/crates/originweave-core/src/webdriver_bidi_error_code.rs b/crates/originweave-core/src/webdriver_bidi_error_code.rs new file mode 100644 index 000000000..bd336cf2d --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_error_code.rs @@ -0,0 +1,170 @@ +/// Typed current WebDriver BiDi protocol error code retained from one validated error response. +/// +/// This vocabulary is deliberately closed over the protocol error codes reviewed by OriginWeave. +/// Unknown wire text remains fail-closed and cannot become typed protocol evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiErrorCode { + /// The command or one of its arguments is invalid. + InvalidArgument, + /// A selector argument is invalid. + InvalidSelector, + /// The referenced browser session does not exist. + InvalidSessionId, + /// The referenced web extension is invalid. + InvalidWebExtension, + /// A requested pointer move target is outside the allowed bounds. + MoveTargetOutOfBounds, + /// The referenced user prompt does not exist. + NoSuchAlert, + /// The referenced client window does not exist. + NoSuchClientWindow, + /// The referenced network collector does not exist. + NoSuchNetworkCollector, + /// The referenced element does not exist. + NoSuchElement, + /// The referenced frame does not exist. + NoSuchFrame, + /// The referenced handle does not exist. + NoSuchHandle, + /// The referenced history entry does not exist. + NoSuchHistoryEntry, + /// The referenced network intercept does not exist. + NoSuchIntercept, + /// The requested network data does not exist. + NoSuchNetworkData, + /// The referenced node does not exist. + NoSuchNode, + /// The referenced network request does not exist. + NoSuchRequest, + /// The referenced screencast does not exist. + NoSuchScreencast, + /// The referenced script does not exist. + NoSuchScript, + /// The referenced storage partition does not exist. + NoSuchStoragePartition, + /// The referenced user context does not exist. + NoSuchUserContext, + /// The referenced web extension does not exist. + NoSuchWebExtension, + /// A browser session could not be created. + SessionNotCreated, + /// The browser could not capture the requested screen image. + UnableToCaptureScreen, + /// The browser could not close as requested. + UnableToCloseBrowser, + /// The browser could not set the requested cookie. + UnableToSetCookie, + /// The browser could not set the requested file input. + UnableToSetFileInput, + /// Requested network data is temporarily unavailable. + UnavailableNetworkData, + /// The supplied storage-partition descriptor is underspecified. + UnderspecifiedStoragePartition, + /// The command is unknown to the remote end. + UnknownCommand, + /// The remote end reported an otherwise unclassified protocol error. + UnknownError, + /// The requested operation is unsupported by the remote end. + UnsupportedOperation, +} + +/// Parse one exact decoded WebDriver BiDi `ErrorCode` value into typed protocol evidence. +pub(crate) fn parse_webdriver_bidi_error_code(value: &[u8]) -> Option { + const ERROR_CODES: &[(&[u8], WebDriverBiDiErrorCode)] = &[ + (b"invalid argument", WebDriverBiDiErrorCode::InvalidArgument), + (b"invalid selector", WebDriverBiDiErrorCode::InvalidSelector), + ( + b"invalid session id", + WebDriverBiDiErrorCode::InvalidSessionId, + ), + ( + b"invalid web extension", + WebDriverBiDiErrorCode::InvalidWebExtension, + ), + ( + b"move target out of bounds", + WebDriverBiDiErrorCode::MoveTargetOutOfBounds, + ), + (b"no such alert", WebDriverBiDiErrorCode::NoSuchAlert), + ( + b"no such client window", + WebDriverBiDiErrorCode::NoSuchClientWindow, + ), + ( + b"no such network collector", + WebDriverBiDiErrorCode::NoSuchNetworkCollector, + ), + (b"no such element", WebDriverBiDiErrorCode::NoSuchElement), + (b"no such frame", WebDriverBiDiErrorCode::NoSuchFrame), + (b"no such handle", WebDriverBiDiErrorCode::NoSuchHandle), + ( + b"no such history entry", + WebDriverBiDiErrorCode::NoSuchHistoryEntry, + ), + ( + b"no such intercept", + WebDriverBiDiErrorCode::NoSuchIntercept, + ), + ( + b"no such network data", + WebDriverBiDiErrorCode::NoSuchNetworkData, + ), + (b"no such node", WebDriverBiDiErrorCode::NoSuchNode), + (b"no such request", WebDriverBiDiErrorCode::NoSuchRequest), + ( + b"no such screencast", + WebDriverBiDiErrorCode::NoSuchScreencast, + ), + (b"no such script", WebDriverBiDiErrorCode::NoSuchScript), + ( + b"no such storage partition", + WebDriverBiDiErrorCode::NoSuchStoragePartition, + ), + ( + b"no such user context", + WebDriverBiDiErrorCode::NoSuchUserContext, + ), + ( + b"no such web extension", + WebDriverBiDiErrorCode::NoSuchWebExtension, + ), + ( + b"session not created", + WebDriverBiDiErrorCode::SessionNotCreated, + ), + ( + b"unable to capture screen", + WebDriverBiDiErrorCode::UnableToCaptureScreen, + ), + ( + b"unable to close browser", + WebDriverBiDiErrorCode::UnableToCloseBrowser, + ), + ( + b"unable to set cookie", + WebDriverBiDiErrorCode::UnableToSetCookie, + ), + ( + b"unable to set file input", + WebDriverBiDiErrorCode::UnableToSetFileInput, + ), + ( + b"unavailable network data", + WebDriverBiDiErrorCode::UnavailableNetworkData, + ), + ( + b"underspecified storage partition", + WebDriverBiDiErrorCode::UnderspecifiedStoragePartition, + ), + (b"unknown command", WebDriverBiDiErrorCode::UnknownCommand), + (b"unknown error", WebDriverBiDiErrorCode::UnknownError), + ( + b"unsupported operation", + WebDriverBiDiErrorCode::UnsupportedOperation, + ), + ]; + + ERROR_CODES + .iter() + .find_map(|(raw, code)| (*raw == value).then_some(*code)) +} diff --git a/crates/originweave-core/src/webdriver_bidi_response_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document.rs new file mode 100644 index 000000000..3bb7a42c4 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_response_document.rs @@ -0,0 +1,98 @@ +use std::fmt; + +/// Maximum raw WebDriver BiDi response-document size admitted before parsing. +/// +/// This is an OriginWeave product safety budget, not a WebDriver BiDi protocol +/// limit. Browser adapters must enforce it before handing raw response text to a +/// JSON parser so an untrusted or malfunctioning peer cannot cause unbounded +/// parser input allocation. +pub const MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES: usize = 65_536; + +/// Fail-closed reasons for rejecting a raw WebDriver BiDi response document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiResponseDocumentAdmissionError { + /// The response contains no JSON document after removing JSON whitespace. + EmptyDocument, + /// The raw response exceeds the OriginWeave pre-parser byte budget. + DocumentTooLarge, + /// The raw response is not valid UTF-8. + InvalidUtf8, + /// The first and last non-whitespace bytes do not delimit a JSON object. + InvalidObjectBoundary, +} + +impl fmt::Display for WebDriverBiDiResponseDocumentAdmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyDocument => formatter.write_str("WebDriver BiDi response document is empty"), + Self::DocumentTooLarge => write!( + formatter, + "WebDriver BiDi response document exceeds {MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES} bytes" + ), + Self::InvalidUtf8 => { + formatter.write_str("WebDriver BiDi response document is not valid UTF-8") + } + Self::InvalidObjectBoundary => formatter.write_str( + "WebDriver BiDi response document must have a top-level JSON object boundary", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiResponseDocumentAdmissionError {} + +/// Exact raw WebDriver BiDi response text admitted to the parser boundary. +/// +/// Construction proves only the OriginWeave byte budget and an obvious +/// top-level object boundary. It deliberately does not claim JSON validity, +/// response correlation, browser authenticity, or action authority. The exact +/// text is retained so downstream parsing/evidence can remain bound to the +/// admitted bytes. +#[derive(Debug, PartialEq, Eq)] +pub struct BoundedWebDriverBiDiResponseDocument { + raw: String, +} + +impl BoundedWebDriverBiDiResponseDocument { + /// Admits exact raw response text under the pre-parser safety contract. + pub fn new(raw: &str) -> Result { + if raw.len() > MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES { + return Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge); + } + + let bounded = raw.trim_matches(|character| matches!(character, ' ' | '\t' | '\r' | '\n')); + if bounded.is_empty() { + return Err(WebDriverBiDiResponseDocumentAdmissionError::EmptyDocument); + } + if !bounded.starts_with('{') || !bounded.ends_with('}') { + return Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary); + } + + Ok(Self { + raw: raw.to_owned(), + }) + } + + /// Admits raw transport bytes after bounding them and validating UTF-8. + /// + /// The byte budget is checked before UTF-8 validation or owned-text + /// allocation. This keeps hostile transport payloads outside the parser + /// boundary until both the resource and text-encoding contracts hold. + pub fn from_utf8_bytes( + raw: &[u8], + ) -> Result { + if raw.len() > MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES { + return Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge); + } + + let raw = std::str::from_utf8(raw) + .map_err(|_| WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8)?; + Self::new(raw) + } + + /// Returns the exact admitted response text, including surrounding JSON whitespace. + #[must_use] + pub fn as_str(&self) -> &str { + &self.raw + } +} diff --git a/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs new file mode 100644 index 000000000..068bdced5 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation.rs @@ -0,0 +1,269 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +mod locate_nodes_result_document; + +use crate::webdriver_bidi_command::{ + CorrelatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseEnvelopeError, +}; +use crate::webdriver_bidi_response_document::BoundedWebDriverBiDiResponseDocument; +use crate::webdriver_bidi_response_envelope::WebDriverBiDiResponseEnvelopeParseError; +use crate::webdriver_bidi_result::{ + ValidatedWebDriverBiDiLocateNodesResult, WebDriverBiDiLocateNodesResultAdmissionError, +}; +use crate::{ + BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, ObservedNodeHandle, + ValidatedBrowserProtocolUse, WebDriverBiDiErrorCode, WebDriverBiDiLocateNodesAdmissionError, +}; + +/// Fail-closed errors while parsing, correlating, classifying, and admitting one bounded +/// WebDriver BiDi `locateNodes` response document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesResponseDocumentError { + /// The bounded document failed complete WebDriver BiDi response-envelope parsing. + Parse(WebDriverBiDiResponseEnvelopeParseError), + /// The parsed envelope failed exact command correlation or success-only conversion. + Envelope(WebDriverBiDiLocateNodesResponseEnvelopeError), + /// The exactly correlated remote end returned a typed WebDriver BiDi protocol error. + ProtocolError(WebDriverBiDiErrorCode), + /// The correlated success result omitted its required `nodes` field. + MissingResultNodes, + /// The correlated success result's `nodes` field was not a JSON array. + InvalidResultNodes, + /// The correlated success result repeated the decoded `nodes` field. + DuplicateResultNodes, + /// One in-budget `nodes` array item was not a JSON object. + InvalidResultNode, + /// One in-budget node object repeated decoded `type` or `sharedId` authority-relevant metadata. + DuplicateResultNodeField, + /// One in-budget node object omitted its required WebDriver BiDi remote-value `type` field. + MissingResultNodeType, + /// One in-budget node object's `type` field was not a JSON string. + InvalidResultNodeType, + /// One present in-budget node `sharedId` field was not a JSON string. + InvalidResultNodeSharedId, + /// Exact command-budget or remote-node admission rejected the wire-derived node batch. + ResultAdmission(WebDriverBiDiLocateNodesResultAdmissionError), + /// Exact current browser authority rejected the wire-derived node batch. + NodeBinding(WebDriverBiDiLocateNodesAdmissionError), + /// A second-pass result parser invariant failed after complete envelope parsing succeeded. + ResultParserInvariant, +} + +impl Display for WebDriverBiDiLocateNodesResponseDocumentError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Parse(error) => write!( + formatter, + "WebDriver BiDi response document rejected envelope parsing: {error}" + ), + Self::Envelope(error) => write!( + formatter, + "WebDriver BiDi response document rejected command correlation: {error}" + ), + Self::ProtocolError(_) => formatter.write_str( + "WebDriver BiDi response document contains a correlated typed protocol error", + ), + Self::MissingResultNodes => { + formatter.write_str("WebDriver BiDi locateNodes result is missing its nodes field") + } + Self::InvalidResultNodes => formatter + .write_str("WebDriver BiDi locateNodes result nodes field is not a JSON array"), + Self::DuplicateResultNodes => formatter.write_str( + "WebDriver BiDi locateNodes result contains duplicate decoded nodes fields", + ), + Self::InvalidResultNode => formatter + .write_str("WebDriver BiDi locateNodes result contains a non-object node item"), + Self::DuplicateResultNodeField => formatter.write_str( + "WebDriver BiDi locateNodes node contains duplicate authority-relevant fields", + ), + Self::MissingResultNodeType => formatter + .write_str("WebDriver BiDi locateNodes node is missing its remote-value type"), + Self::InvalidResultNodeType => formatter + .write_str("WebDriver BiDi locateNodes node type is not a JSON string"), + Self::InvalidResultNodeSharedId => formatter + .write_str("WebDriver BiDi locateNodes node sharedId is not a JSON string"), + Self::ResultAdmission(error) => write!( + formatter, + "WebDriver BiDi locateNodes wire result rejected node admission: {error}" + ), + Self::NodeBinding(error) => write!( + formatter, + "WebDriver BiDi locateNodes wire result rejected current browser authority: {error}" + ), + Self::ResultParserInvariant => formatter.write_str( + "WebDriver BiDi locateNodes result parser invariant failed after envelope validation", + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesResponseDocumentError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Parse(error) => Some(error), + Self::Envelope(error) => Some(error), + Self::ResultAdmission(error) => Some(error), + Self::NodeBinding(error) => Some(error), + Self::ProtocolError(_) + | Self::MissingResultNodes + | Self::InvalidResultNodes + | Self::DuplicateResultNodes + | Self::InvalidResultNode + | Self::DuplicateResultNodeField + | Self::MissingResultNodeType + | Self::InvalidResultNodeType + | Self::InvalidResultNodeSharedId + | Self::ResultParserInvariant => None, + } + } +} + +impl WebDriverBiDiLocateNodesCommand { + /// Consume this command and one bounded raw response through parsing and exact correlation. + /// + /// The document must first pass complete response-envelope parsing. Only the resulting typed + /// response kind and protocol-range response id are then admitted to the existing exact command + /// correlation boundary. Parser and correlation failures remain distinguishable and preserve + /// their causal error sources. This boundary does not authenticate Chromium, ChromeDriver, or + /// WebSocket transport provenance, validate `locateNodes` result nodes, mint node authority, + /// authorize an Agent action, execute browser input, or prove a post-condition. + pub fn correlate_response_document( + self, + document: BoundedWebDriverBiDiResponseDocument, + ) -> Result< + CorrelatedWebDriverBiDiLocateNodesResponse, + WebDriverBiDiLocateNodesResponseDocumentError, + > { + let parsed = document + .parse_command_response() + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Parse)?; + self.correlate_response_envelope(parsed.kind(), parsed.response_id()) + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope) + } + + /// Consume one bounded raw `locateNodes` response through exact wire-derived node admission. + /// + /// The same bounded document first passes the complete response-envelope parser and exact + /// command-id correlation. A parsed error with JSON `null` id remains uncorrelatable and fails + /// closed before its typed error code can influence recovery. An exactly correlated protocol + /// error retains its reviewed typed error code and stops before result admission; unknown + /// error-code text still fails closed during complete envelope parsing. Only a correlated + /// success proceeds to the result parser, which derives the exact `result.nodes` array from that + /// already-validated wire document. The command's exact `maxNodeCount` is carried into this + /// parser so overflow items are consumed only as generic JSON and produce the existing + /// result-budget failure before authority-relevant node metadata is decoded or normalized. + /// Decoded duplicate `nodes`, and duplicate or malformed in-budget `type`/`sharedId` fields, + /// fail closed. JSON-escaped protocol metadata is decoded before admission, and callers cannot + /// supply replacement node metadata to this method. + /// + /// Success remains untrusted transport evidence. It does not authenticate Chromium, + /// ChromeDriver, WebSocket/TLS provenance, or an adapter process; prove current + /// session/context/origin/document authority; mint OriginWeave node handles; authorize policy + /// or typed input; execute browser I/O; or prove a post-condition. + pub fn admit_response_document_nodes( + self, + document: BoundedWebDriverBiDiResponseDocument, + ) -> Result< + ValidatedWebDriverBiDiLocateNodesResult, + WebDriverBiDiLocateNodesResponseDocumentError, + > { + let parsed = document + .parse_command_response() + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::Parse)?; + let response_id = match parsed.response_id() { + Some(response_id) => response_id, + None => { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + )); + } + }; + let validated = self.correlate_response_id(response_id).map_err(|error| { + WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation(error), + ) + })?; + if let Some(error_code) = parsed.error_code() { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError(error_code)); + } + let wire_nodes = locate_nodes_result_document::parse_wire_locate_nodes_result_bounded( + parsed.as_str(), + validated.max_node_count(), + )?; + let admission_parts = wire_nodes + .iter() + .map(locate_nodes_result_document::WireLocateNodesNode::as_admission_parts) + .collect::>(); + validated + .admit_result_nodes(&admission_parts) + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission) + } + + /// Consume one bounded raw `locateNodes` response through wire admission and current authority. + /// + /// This is the direct composition boundary from the exact parsed wire document to current + /// OriginWeave node authority. The caller cannot replace the response kind, response id, result + /// nodes, browsing-context identifier, or command result budget between wire parsing and node + /// binding. After wire-derived admission succeeds, the supplied WebDriver BiDi + /// `SemanticObservation` proof and exact current session/context/origin/document epoch are + /// revalidated by [`ValidatedWebDriverBiDiLocateNodesResult::bind_current_nodes`]. + /// + /// Success mints only [`ObservedNodeHandle`] values. It still does not authenticate Chromium, + /// ChromeDriver, WebSocket/TLS provenance, or the adapter process; authorize policy or typed + /// input; execute browser I/O; or prove an action post-condition. + pub fn bind_response_document_nodes( + self, + document: BoundedWebDriverBiDiResponseDocument, + validated: ValidatedBrowserProtocolUse, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + self.admit_response_document_nodes(document)? + .bind_current_nodes(validated, authority_registry, target) + .map_err(WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding) + } +} + +#[cfg(test)] +mod tests { + use super::WebDriverBiDiLocateNodesResponseDocumentError; + use super::locate_nodes_result_document::{ + parse_wire_locate_nodes_result, parse_wire_locate_nodes_result_bounded, + }; + + #[test] + fn wire_node_admission_parts_preserve_wire_derived_metadata() { + let nodes = parse_wire_locate_nodes_result(concat!( + "{\"result\":{\"nodes\":[", + "{\"type\":\"node\",\"sharedId\":\"shared-1\"},", + "{\"type\":\"window\"}", + "]}}" + )) + .into_iter() + .flatten() + .collect::>(); + + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].as_admission_parts(), ("node", Some("shared-1"))); + assert_eq!(nodes[1].as_admission_parts(), ("window", None)); + } + + #[test] + fn bounded_overflow_parser_preserves_invalid_generic_json_invariant() { + let result = parse_wire_locate_nodes_result_bounded( + concat!( + "{\"result\":{\"nodes\":[", + "{\"type\":\"node\",\"sharedId\":\"shared-1\"},", + "?]}}" + ), + 1, + ); + + assert_eq!( + result.err(), + Some(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant) + ); + } +} diff --git a/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs new file mode 100644 index 000000000..409d0f6f8 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_response_document_correlation/locate_nodes_result_document.rs @@ -0,0 +1,714 @@ +use super::WebDriverBiDiLocateNodesResponseDocumentError; + +pub(super) struct WireLocateNodesNode { + remote_type: String, + shared_id: Option, +} + +impl WireLocateNodesNode { + pub(super) fn as_admission_parts(&self) -> (&str, Option<&str>) { + (self.remote_type.as_str(), self.shared_id.as_deref()) + } + + fn overflow_count_marker() -> Self { + Self { + remote_type: String::new(), + shared_id: None, + } + } +} + +#[cfg(test)] +pub(super) fn parse_wire_locate_nodes_result( + input: &str, +) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + ResultParser::new(input).parse() +} + +pub(super) fn parse_wire_locate_nodes_result_bounded( + input: &str, + max_node_count: u16, +) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + ResultParser::with_node_budget(input, usize::from(max_node_count)).parse() +} + +struct ResultParser<'input> { + input: &'input str, + position: usize, + max_node_count: Option, +} + +impl<'input> ResultParser<'input> { + #[cfg(test)] + const fn new(input: &'input str) -> Self { + Self { + input, + position: 0, + max_node_count: None, + } + } + + const fn with_node_budget(input: &'input str, max_node_count: usize) -> Self { + Self { + input, + position: 0, + max_node_count: Some(max_node_count), + } + } + + fn parse( + mut self, + ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + self.skip_whitespace(); + self.expect_byte(b'{')?; + self.skip_whitespace(); + + loop { + if self.peek_byte() == Some(b'}') { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant); + } + let field_name = self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + if field_name == "result" { + return self.parse_result_object(); + } + self.skip_value()?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + } + + fn parse_result_object( + &mut self, + ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + if self.peek_byte() != Some(b'{') { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes); + } + self.position += 1; + self.skip_whitespace(); + let mut nodes = None; + + if self.peek_byte() == Some(b'}') { + self.position += 1; + return Err(WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes); + } + + loop { + let field_name = self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + if field_name == "nodes" { + if nodes.is_some() { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodes, + ); + } + nodes = Some(self.parse_nodes_array()?); + } else { + self.skip_value()?; + } + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + self.position += 1; + break; + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + + nodes.ok_or(WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes) + } + + fn parse_nodes_array( + &mut self, + ) -> Result, WebDriverBiDiLocateNodesResponseDocumentError> { + if self.peek_byte() != Some(b'[') { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes); + } + self.position += 1; + self.skip_whitespace(); + let mut nodes = Vec::new(); + let mut over_budget = false; + if self.peek_byte() == Some(b']') { + self.position += 1; + return Ok(nodes); + } + + loop { + let at_node_budget = self + .max_node_count + .is_some_and(|max_node_count| nodes.len() >= max_node_count); + if over_budget || at_node_budget { + self.skip_value()?; + if !over_budget { + nodes.push(WireLocateNodesNode::overflow_count_marker()); + over_budget = true; + } + } else { + nodes.push(self.parse_node()?); + } + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b']') => { + self.position += 1; + return Ok(nodes); + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + } + + fn parse_node( + &mut self, + ) -> Result { + if self.peek_byte() != Some(b'{') { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNode); + } + self.position += 1; + self.skip_whitespace(); + let mut remote_type = None; + let mut shared_id = None; + let mut shared_id_seen = false; + + if self.peek_byte() == Some(b'}') { + self.position += 1; + return Err(WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType); + } + + loop { + let field_name = self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + match field_name.as_str() { + "type" => { + if remote_type.is_some() { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + ); + } + if self.peek_byte() != Some(b'"') { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeType, + ); + } + remote_type = Some(self.parse_string()?); + } + "sharedId" => { + if shared_id_seen { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + ); + } + shared_id_seen = true; + if self.peek_byte() != Some(b'"') { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeSharedId, + ); + } + shared_id = Some(self.parse_string()?); + } + _ => self.skip_value()?, + } + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + self.position += 1; + break; + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + + Ok(WireLocateNodesNode { + remote_type: remote_type + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType)?, + shared_id, + }) + } + + fn skip_value(&mut self) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + match self.peek_byte() { + Some(b'{') => self.skip_object(), + Some(b'[') => self.skip_array(), + Some(b'"') => { + let _value = self.parse_string()?; + Ok(()) + } + Some(b'-' | b'0'..=b'9') => self.skip_number(), + Some(b't') => self.skip_literal(b"true"), + Some(b'f') => self.skip_literal(b"false"), + Some(b'n') => self.skip_literal(b"null"), + _ => Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant), + } + } + + fn skip_object(&mut self) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + self.expect_byte(b'{')?; + self.skip_whitespace(); + if self.peek_byte() == Some(b'}') { + self.position += 1; + return Ok(()); + } + loop { + let _field_name = self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + self.skip_value()?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + self.position += 1; + return Ok(()); + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + } + + fn skip_array(&mut self) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + self.expect_byte(b'[')?; + self.skip_whitespace(); + if self.peek_byte() == Some(b']') { + self.position += 1; + return Ok(()); + } + loop { + self.skip_value()?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b']') => { + self.position += 1; + return Ok(()); + } + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + } + } + } + + fn skip_number(&mut self) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + let start = self.position; + while let Some(byte) = self.peek_byte() { + if matches!(byte, b',' | b']' | b'}' | b' ' | b'\t' | b'\r' | b'\n') { + break; + } + self.position += 1; + } + if self.position == start { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant); + } + Ok(()) + } + + fn skip_literal( + &mut self, + literal: &[u8], + ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + for expected in literal { + self.expect_byte(*expected)?; + } + Ok(()) + } + + fn parse_string(&mut self) -> Result { + self.expect_byte(b'"')?; + let mut decoded = String::new(); + let mut literal_start = self.position; + loop { + let byte = self + .peek_byte() + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; + match byte { + b'"' => { + decoded.push_str(&self.input[literal_start..self.position]); + self.position += 1; + return Ok(decoded); + } + b'\\' => { + decoded.push_str(&self.input[literal_start..self.position]); + self.position += 1; + self.parse_escape(&mut decoded)?; + literal_start = self.position; + } + 0x00..=0x1f => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + _ => { + self.position += 1; + } + } + } + } + + fn parse_escape( + &mut self, + decoded: &mut String, + ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + let escaped = self + .peek_byte() + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; + self.position += 1; + match escaped { + b'"' => decoded.push('"'), + b'\\' => decoded.push('\\'), + b'/' => decoded.push('/'), + b'b' => decoded.push('\u{0008}'), + b'f' => decoded.push('\u{000c}'), + b'n' => decoded.push('\n'), + b'r' => decoded.push('\r'), + b't' => decoded.push('\t'), + b'u' => self.parse_unicode_escape(decoded)?, + _ => return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant), + } + Ok(()) + } + + fn parse_unicode_escape( + &mut self, + decoded: &mut String, + ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + let first = self.parse_hex_quad()?; + let scalar = if (0xd800..=0xdbff).contains(&first) { + self.expect_byte(b'\\')?; + self.expect_byte(b'u')?; + let second = self.parse_hex_quad()?; + if !(0xdc00..=0xdfff).contains(&second) { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant); + } + 0x1_0000 + (((u32::from(first) - 0xd800) << 10) | (u32::from(second) - 0xdc00)) + } else { + u32::from(first) + }; + let character = char::from_u32(scalar) + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; + decoded.push(character); + Ok(()) + } + + fn parse_hex_quad(&mut self) -> Result { + let mut value = 0_u16; + for _ in 0..4 { + let byte = self + .peek_byte() + .ok_or(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant)?; + self.position += 1; + let digit = match byte { + b'0'..=b'9' => u16::from(byte - b'0'), + b'a'..=b'f' => u16::from(byte - b'a' + 10), + b'A'..=b'F' => u16::from(byte - b'A' + 10), + _ => { + return Err( + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ); + } + }; + value = (value << 4) | digit; + } + Ok(value) + } + + fn expect_byte( + &mut self, + expected: u8, + ) -> Result<(), WebDriverBiDiLocateNodesResponseDocumentError> { + if self.peek_byte() != Some(expected) { + return Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant); + } + self.position += 1; + Ok(()) + } + + fn skip_whitespace(&mut self) { + while matches!(self.peek_byte(), Some(b' ' | b'\t' | b'\r' | b'\n')) { + self.position += 1; + } + } + + fn peek_byte(&self) -> Option { + self.input.as_bytes().get(self.position).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const INVARIANT: WebDriverBiDiLocateNodesResponseDocumentError = + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant; + + #[test] + fn second_pass_parser_covers_valid_skipped_value_and_escape_shapes() { + let raw = concat!( + " \n{\t\"metadata\": [true,false,null,{\"a\":1,\"b\":2}],", + "\"result\":{", + "\"emptyObject\":{},\"emptyArray\":[],", + "\"object\":{\"first\":1,\"second\":2},", + "\"array\":[true,false,null],", + "\"escaped\":\"\\\"\\\\\\/\\b\\f\\n\\r\\t\",", + "\"utf8\":\"é\",", + "\"number\":-1.25e+2,\"truth\":true,\"falsehood\":false,\"nothing\":null,", + "\"nodes\":[{", + "\"ignored\":{\"nested\":[1,2]},", + "\"type\":\"no\\u0064e\",", + "\"sharedId\":\"node-\\u0041-\\u00E9-\\u263A-\\uD83D\\uDE00-\\u00af-\\u00AF\"", + "}]}}" + ); + let nodes = parse_wire_locate_nodes_result(raw) + .into_iter() + .flatten() + .collect::>(); + + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].remote_type, "node"); + assert_eq!(nodes[0].shared_id.as_deref(), Some("node-A-é-☺-😀-¯-¯")); + } + + #[test] + fn second_pass_parser_rejects_structural_and_typed_result_faults() { + let cases = [ + ("", INVARIANT), + ("{}", INVARIANT), + (r#"{"metadata":0}"#, INVARIANT), + (r#"{"metadata":0]"#, INVARIANT), + (r#"{"metadata" 0,"result":{"nodes":[]}}"#, INVARIANT), + (r#"{metadata:0,"result":{"nodes":[]}}"#, INVARIANT), + ( + r#"{"result":[]}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes, + ), + ( + r#"{"result":{}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + ), + ( + r#"{"result":{"other":0}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + ), + ( + r#"{"result":{"nodes":0}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes, + ), + ( + r#"{"result":{"nodes":[0]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNode, + ), + ( + r#"{"result":{"nodes":[{}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType, + ), + ( + r#"{"result":{"nodes":[{"sharedId":"node-a"}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType, + ), + ( + r#"{"result":{"nodes":[{"type":0}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeType, + ), + ( + r#"{"result":{"nodes":[{"type":"node","sharedId":0}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeSharedId, + ), + ( + r#"{"result":{"nodes":[],"nodes":[]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodes, + ), + ( + r#"{"result":{"nodes":[{"type":"node","type":"node"}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + ), + ( + r#"{"result":{"nodes":[{"type":"node","sharedId":"a","sharedId":"b"}]}}"#, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + ), + (r#"{"result":{"nodes":[] "other":0}}"#, INVARIANT), + (r#"{"result":{"nodes":[{"type":"node"} 0]}}"#, INVARIANT), + ( + r#"{"result":{"nodes":[{"type":"node" "sharedId":"node-a"}]}}"#, + INVARIANT, + ), + (r#"{"metadata":?,"result":{"nodes":[]}}"#, INVARIANT), + (r#"{"result":{?}}"#, INVARIANT), + (r#"{"result":{"nodes" []}}"#, INVARIANT), + (r#"{"result":{"other":?,"nodes":[]}}"#, INVARIANT), + (r#"{"result":{"nodes":[{?}]}}"#, INVARIANT), + (r#"{"result":{"nodes":[{"type" "node"}]}}"#, INVARIANT), + (r#"{"result":{"nodes":[{"type":"\x"}]}}"#, INVARIANT), + ( + r#"{"result":{"nodes":[{"type":"node","sharedId":"\x"}]}}"#, + INVARIANT, + ), + ( + r#"{"result":{"nodes":[{"ignored":?,"type":"node"}]}}"#, + INVARIANT, + ), + ]; + + for (raw, expected) in cases { + assert_eq!(parse_wire_locate_nodes_result(raw).err(), Some(expected)); + } + } + + #[test] + fn bounded_parser_uses_one_count_marker_and_skips_overflow_node_shapes() { + let nodes = parse_wire_locate_nodes_result_bounded( + r#"{"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":1},{"type":2}]}}"#, + 1, + ) + .into_iter() + .flatten() + .collect::>(); + + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].remote_type, "node"); + assert_eq!(nodes[1].as_admission_parts(), ("", None)); + } + + #[test] + fn skip_helpers_cover_empty_nonempty_and_malformed_containers() { + let mut empty_object = ResultParser::new("{}"); + assert_eq!(empty_object.skip_object(), Ok(())); + + let mut object = ResultParser::new(r#"{"a":0,"b":1}"#); + assert_eq!(object.skip_object(), Ok(())); + + let mut malformed_object = ResultParser::new(r#"{"a":0 "b":1}"#); + assert_eq!(malformed_object.skip_object(), Err(INVARIANT)); + + let mut empty_array = ResultParser::new("[]"); + assert_eq!(empty_array.skip_array(), Ok(())); + + let mut array = ResultParser::new("[0,1]"); + assert_eq!(array.skip_array(), Ok(())); + + let mut malformed_array = ResultParser::new("[0 1]"); + assert_eq!(malformed_array.skip_array(), Err(INVARIANT)); + + let mut unknown = ResultParser::new("?"); + assert_eq!(unknown.skip_value(), Err(INVARIANT)); + + let mut empty_number = ResultParser::new(""); + assert_eq!(empty_number.skip_number(), Err(INVARIANT)); + + let mut terminal_number = ResultParser::new("123"); + assert_eq!(terminal_number.skip_number(), Ok(())); + assert_eq!(terminal_number.peek_byte(), None); + + let mut malformed_string_value = ResultParser::new(r#""\x""#); + assert_eq!(malformed_string_value.skip_value(), Err(INVARIANT)); + + let mut wrong_object_opener = ResultParser::new("[]"); + assert_eq!(wrong_object_opener.skip_object(), Err(INVARIANT)); + + let mut malformed_object_key = ResultParser::new("{?}"); + assert_eq!(malformed_object_key.skip_object(), Err(INVARIANT)); + + let mut missing_object_colon = ResultParser::new(r#"{"a" 0}"#); + assert_eq!(missing_object_colon.skip_object(), Err(INVARIANT)); + + let mut malformed_object_value = ResultParser::new(r#"{"a":?}"#); + assert_eq!(malformed_object_value.skip_object(), Err(INVARIANT)); + + let mut wrong_array_opener = ResultParser::new("{}"); + assert_eq!(wrong_array_opener.skip_array(), Err(INVARIANT)); + + let mut malformed_array_value = ResultParser::new("[?]"); + assert_eq!(malformed_array_value.skip_array(), Err(INVARIANT)); + + let mut truncated_literal = ResultParser::new("tru"); + assert_eq!(truncated_literal.skip_literal(b"true"), Err(INVARIANT)); + } + + #[test] + fn string_decoder_rejects_all_second_pass_escape_invariants() { + let mut missing_open_quote = ResultParser::new("plain"); + assert_eq!(missing_open_quote.parse_string(), Err(INVARIANT)); + + let mut unterminated = ResultParser::new("\"plain"); + assert_eq!(unterminated.parse_string(), Err(INVARIANT)); + + let mut control = ResultParser::new("\"\u{0001}\""); + assert_eq!(control.parse_string(), Err(INVARIANT)); + + let mut missing_escape = ResultParser::new("\"\\"); + assert_eq!(missing_escape.parse_string(), Err(INVARIANT)); + + let mut invalid_escape = ResultParser::new(r#""\x""#); + assert_eq!(invalid_escape.parse_string(), Err(INVARIANT)); + + for raw in [ + r#""\uD83D""#, + r#""\uD83D\x0000""#, + r#""\uD83D\u0041""#, + "\"\\uD83D\\u12", + r#""\uDE00""#, + r#""\u12""#, + "\"\\u12", + r#""\u00G0""#, + ] { + let mut parser = ResultParser::new(raw); + assert_eq!(parser.parse_string(), Err(INVARIANT)); + } + } +} diff --git a/crates/originweave-core/src/webdriver_bidi_response_envelope.rs b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs new file mode 100644 index 000000000..cf22c762c --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_response_envelope.rs @@ -0,0 +1,625 @@ +use std::{error::Error, fmt}; + +use crate::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_COMMAND_ID, + WebDriverBiDiCommandResponseKind, + webdriver_bidi_error_code::{WebDriverBiDiErrorCode, parse_webdriver_bidi_error_code}, +}; + +/// Maximum accepted JSON container nesting depth for one WebDriver BiDi response document. +/// +/// The top-level response object is depth 1. The limit is an OriginWeave resource-safety +/// budget, not a WebDriver BiDi protocol maximum. +pub const MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH: usize = 64; + +/// Maximum accepted number of fields in one top-level WebDriver BiDi response object. +/// +/// The limit is an OriginWeave resource-safety budget, not a WebDriver BiDi protocol maximum. +pub const MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS: usize = 64; + +/// Fail-closed reasons a bounded WebDriver BiDi response document cannot become typed envelope +/// evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiResponseEnvelopeParseError { + /// The document is not syntactically valid JSON with one complete top-level object. + InvalidJson, + /// JSON object/array nesting exceeded the configured parser safety budget. + JsonDepthExceeded, + /// The top-level response object contains more fields than the configured safety budget. + TopLevelFieldCountExceeded, + /// The top-level response object repeats a field after JSON string escape decoding. + DuplicateTopLevelField, + /// The response object omits the required `type` discriminator. + MissingResponseType, + /// The response `type` is not exactly `success` or `error`. + UnexpectedResponseType, + /// The response object omits the required `id` field. + MissingResponseId, + /// The response `id` is not a protocol-range JSON integer, or is `null` where forbidden. + InvalidResponseId, + /// The selected response kind omits one of its required payload fields. + MissingRequiredPayload, + /// A required response payload field has the wrong JSON value type. + InvalidRequiredPayloadType, + /// The error response uses a string outside the current WebDriver BiDi `ErrorCode` vocabulary. + UnexpectedErrorCode, +} + +impl fmt::Display for WebDriverBiDiResponseEnvelopeParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidJson => "WebDriver BiDi response document is not valid JSON", + Self::JsonDepthExceeded => { + "WebDriver BiDi response JSON depth exceeds the safety budget" + } + Self::TopLevelFieldCountExceeded => { + "WebDriver BiDi response top-level field count exceeds the safety budget" + } + Self::DuplicateTopLevelField => { + "WebDriver BiDi response contains a duplicate top-level field" + } + Self::MissingResponseType => "WebDriver BiDi response is missing its type field", + Self::UnexpectedResponseType => "WebDriver BiDi response type is not success or error", + Self::MissingResponseId => "WebDriver BiDi response is missing its id field", + Self::InvalidResponseId => "WebDriver BiDi response id is invalid", + Self::MissingRequiredPayload => { + "WebDriver BiDi response is missing a required payload field" + } + Self::InvalidRequiredPayloadType => { + "WebDriver BiDi response payload field has an invalid JSON type" + } + Self::UnexpectedErrorCode => "WebDriver BiDi response error code is not recognized", + }) + } +} + +impl Error for WebDriverBiDiResponseEnvelopeParseError {} + +/// Typed evidence that one bounded raw document is a syntactically valid WebDriver BiDi command +/// response envelope. +/// +/// The value retains the exact admitted wire text, command-response kind, parsed response +/// identifier, and the typed protocol error code for an error response. Parsing does not +/// authenticate a browser or transport and does not grant browser, node, policy, or Agent +/// authority. +#[derive(Debug, PartialEq, Eq)] +pub struct ParsedWebDriverBiDiCommandResponseEnvelope { + document: BoundedWebDriverBiDiResponseDocument, + kind: WebDriverBiDiCommandResponseKind, + response_id: Option, + error_code: Option, +} + +impl ParsedWebDriverBiDiCommandResponseEnvelope { + /// Returns whether the parsed command response is a success or error envelope. + #[must_use] + pub const fn kind(&self) -> WebDriverBiDiCommandResponseKind { + self.kind + } + + /// Returns the parsed command identifier, or `None` only for an error response whose required + /// `id` field was explicitly JSON `null`. + #[must_use] + pub const fn response_id(&self) -> Option { + self.response_id + } + + /// Returns the typed protocol error code, or `None` for a success response. + /// + /// The value is derived from the same decoded top-level `error` field that passed complete + /// envelope validation; callers therefore do not need to reparse untrusted wire text merely to + /// classify a recoverable protocol failure. + #[must_use] + pub const fn error_code(&self) -> Option { + self.error_code + } + + /// Returns the exact bounded wire text from which this envelope evidence was parsed. + #[must_use] + pub fn as_str(&self) -> &str { + self.document.as_str() + } +} + +impl BoundedWebDriverBiDiResponseDocument { + /// Parses this already-bounded raw document into typed command-response envelope evidence. + /// + /// Complete JSON syntax, decoded top-level field uniqueness, response-kind requirements, + /// protocol-range response identifiers, typed current error-code classification, and explicit + /// parser resource budgets are enforced before the value can be used by a later correlation + /// boundary. + pub fn parse_command_response( + self, + ) -> Result + { + let parsed = ResponseEnvelopeParser::new(self.as_str()).parse()?; + Ok(ParsedWebDriverBiDiCommandResponseEnvelope { + document: self, + kind: parsed.kind, + response_id: parsed.response_id, + error_code: parsed.error_code, + }) + } +} + +#[derive(Debug, PartialEq, Eq)] +enum ParsedJsonValue { + Object, + Array, + String(Vec), + Number(String), + Boolean, + Null, +} + +struct ParsedEnvelopeFields { + kind: WebDriverBiDiCommandResponseKind, + response_id: Option, + error_code: Option, +} + +struct ResponseEnvelopeParser<'input> { + input: &'input str, + position: usize, +} + +impl<'input> ResponseEnvelopeParser<'input> { + const fn new(input: &'input str) -> Self { + Self { input, position: 0 } + } + + fn parse(mut self) -> Result { + self.skip_whitespace(); + // The bounded-document constructor proves the first non-whitespace byte is `{`. + self.position += 1; + self.skip_whitespace(); + + let mut seen_fields: Vec> = Vec::new(); + let mut response_type = None; + let mut response_id = None; + let mut result = None; + let mut error_code = None; + let mut message = None; + let mut stacktrace = None; + + if self.peek_byte() != Some(b'}') { + loop { + if seen_fields.len() >= MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS { + return Err( + WebDriverBiDiResponseEnvelopeParseError::TopLevelFieldCountExceeded, + ); + } + + let field_name = self.parse_string()?; + if seen_fields.contains(&field_name) { + return Err(WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField); + } + seen_fields.push(field_name.clone()); + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + let value = self.parse_value(2)?; + + match field_name.as_slice() { + b"type" => response_type = Some(value), + b"id" => response_id = Some(value), + b"result" => result = Some(value), + b"error" => error_code = Some(value), + b"message" => message = Some(value), + b"stacktrace" => stacktrace = Some(value), + _ => {} + } + + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + self.position += 1; + break; + } + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + } + } else { + self.position += 1; + } + + self.skip_whitespace(); + if self.position != self.input.len() { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + + let kind = Self::parse_response_type(response_type)?; + let response_id = Self::parse_response_id(response_id, kind)?; + let error_code = + Self::validate_required_payload(kind, result, error_code, message, stacktrace)?; + + Ok(ParsedEnvelopeFields { + kind, + response_id, + error_code, + }) + } + + fn parse_response_type( + value: Option, + ) -> Result { + let value = value.ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingResponseType)?; + match value { + ParsedJsonValue::String(value) if value == b"success" => { + Ok(WebDriverBiDiCommandResponseKind::Success) + } + ParsedJsonValue::String(value) if value == b"error" => { + Ok(WebDriverBiDiCommandResponseKind::Error) + } + _ => Err(WebDriverBiDiResponseEnvelopeParseError::UnexpectedResponseType), + } + } + + fn parse_response_id( + value: Option, + kind: WebDriverBiDiCommandResponseKind, + ) -> Result, WebDriverBiDiResponseEnvelopeParseError> { + let value = value.ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingResponseId)?; + let raw = match value { + ParsedJsonValue::Null if kind == WebDriverBiDiCommandResponseKind::Error => { + return Ok(None); + } + ParsedJsonValue::Number(raw) => raw, + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId), + }; + if !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId); + } + let parsed = raw + .parse::() + .map_err(|_| WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId)?; + if parsed > MAX_WEBDRIVER_BIDI_COMMAND_ID { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId); + } + Ok(Some(parsed)) + } + + fn validate_required_payload( + kind: WebDriverBiDiCommandResponseKind, + result: Option, + error_code: Option, + message: Option, + stacktrace: Option, + ) -> Result, WebDriverBiDiResponseEnvelopeParseError> { + match kind { + WebDriverBiDiCommandResponseKind::Success => { + let result = result + .ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; + if !matches!(result, ParsedJsonValue::Object) { + return Err( + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ); + } + Ok(None) + } + WebDriverBiDiCommandResponseKind::Error => { + let error_code = error_code + .ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; + let message = message + .ok_or(WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload)?; + let ParsedJsonValue::String(error_code) = error_code else { + return Err( + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ); + }; + if !matches!(message, ParsedJsonValue::String(_)) { + return Err( + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ); + } + let error_code = parse_webdriver_bidi_error_code(&error_code) + .ok_or(WebDriverBiDiResponseEnvelopeParseError::UnexpectedErrorCode)?; + if let Some(stacktrace) = stacktrace + && !matches!(stacktrace, ParsedJsonValue::String(_)) + { + return Err( + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ); + } + Ok(Some(error_code)) + } + } + } + + fn parse_value( + &mut self, + container_depth: usize, + ) -> Result { + match self.peek_byte() { + Some(b'{') => { + self.parse_object(container_depth)?; + Ok(ParsedJsonValue::Object) + } + Some(b'[') => { + self.parse_array(container_depth)?; + Ok(ParsedJsonValue::Array) + } + Some(b'"') => Ok(ParsedJsonValue::String(self.parse_string()?)), + Some(b'-' | b'0'..=b'9') => Ok(ParsedJsonValue::Number(self.parse_number()?)), + Some(b't') => { + self.parse_literal(b"true")?; + Ok(ParsedJsonValue::Boolean) + } + Some(b'f') => { + self.parse_literal(b"false")?; + Ok(ParsedJsonValue::Boolean) + } + Some(b'n') => { + self.parse_literal(b"null")?; + Ok(ParsedJsonValue::Null) + } + _ => Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + } + + fn parse_object( + &mut self, + depth: usize, + ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + Self::require_depth(depth)?; + self.position += 1; + self.skip_whitespace(); + if self.peek_byte() == Some(b'}') { + self.position += 1; + return Ok(()); + } + + loop { + self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + self.parse_value(depth + 1)?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b'}') => { + self.position += 1; + return Ok(()); + } + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + } + } + + fn parse_array(&mut self, depth: usize) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + Self::require_depth(depth)?; + self.position += 1; + self.skip_whitespace(); + if self.peek_byte() == Some(b']') { + self.position += 1; + return Ok(()); + } + + loop { + self.parse_value(depth + 1)?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + } + Some(b']') => { + self.position += 1; + return Ok(()); + } + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + } + } + + fn require_depth(depth: usize) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + if depth > MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH { + Err(WebDriverBiDiResponseEnvelopeParseError::JsonDepthExceeded) + } else { + Ok(()) + } + } + + fn parse_string(&mut self) -> Result, WebDriverBiDiResponseEnvelopeParseError> { + self.expect_byte(b'"')?; + let mut decoded = Vec::new(); + loop { + let byte = self + .peek_byte() + .ok_or(WebDriverBiDiResponseEnvelopeParseError::InvalidJson)?; + match byte { + b'"' => { + self.position += 1; + return Ok(decoded); + } + b'\\' => { + self.position += 1; + self.parse_escape(&mut decoded)?; + } + 0x00..=0x1f => { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + _ => { + decoded.push(byte); + self.position += 1; + } + } + } + } + + fn parse_escape( + &mut self, + decoded: &mut Vec, + ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + // The bounded top-level object guarantees a following byte; map any violated internal + // invariant to the existing fail-closed invalid-JSON path without a second unreachable branch. + let escaped = self.peek_byte().unwrap_or_default(); + self.position += 1; + match escaped { + b'"' => decoded.push(b'"'), + b'\\' => decoded.push(b'\\'), + b'/' => decoded.push(b'/'), + b'b' => decoded.push(0x08), + b'f' => decoded.push(0x0c), + b'n' => decoded.push(b'\n'), + b'r' => decoded.push(b'\r'), + b't' => decoded.push(b'\t'), + b'u' => { + let scalar = self.parse_unicode_escape()?; + Self::push_utf8(decoded, scalar); + } + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + Ok(()) + } + + fn parse_unicode_escape(&mut self) -> Result { + let first = self.parse_hex_code_unit()?; + if (0xd800..=0xdbff).contains(&first) { + if self.peek_byte() != Some(b'\\') { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.position += 1; + if self.peek_byte() != Some(b'u') { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.position += 1; + let second = self.parse_hex_code_unit()?; + if !(0xdc00..=0xdfff).contains(&second) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + return Ok(0x1_0000 + + ((u32::from(first) - 0xd800) << 10) + + (u32::from(second) - 0xdc00)); + } + if (0xdc00..=0xdfff).contains(&first) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + Ok(u32::from(first)) + } + + fn push_utf8(decoded: &mut Vec, scalar: u32) { + if scalar <= 0x7f { + decoded.push(scalar as u8); + } else if scalar <= 0x7ff { + decoded.push((0xc0 | (scalar >> 6)) as u8); + decoded.push((0x80 | (scalar & 0x3f)) as u8); + } else if scalar <= 0xffff { + decoded.push((0xe0 | (scalar >> 12)) as u8); + decoded.push((0x80 | ((scalar >> 6) & 0x3f)) as u8); + decoded.push((0x80 | (scalar & 0x3f)) as u8); + } else { + decoded.push((0xf0 | (scalar >> 18)) as u8); + decoded.push((0x80 | ((scalar >> 12) & 0x3f)) as u8); + decoded.push((0x80 | ((scalar >> 6) & 0x3f)) as u8); + decoded.push((0x80 | (scalar & 0x3f)) as u8); + } + } + + fn parse_hex_code_unit(&mut self) -> Result { + let mut value = 0_u16; + for _ in 0..4 { + // A truncated escape cannot run past the admitted top-level closing `}`. If an + // internal invariant is ever violated, zero still deterministically fails hex decoding. + let byte = self.peek_byte().unwrap_or_default(); + let digit = Self::hex_value(byte) + .ok_or(WebDriverBiDiResponseEnvelopeParseError::InvalidJson)?; + value = (value << 4) | u16::from(digit); + self.position += 1; + } + Ok(value) + } + + const fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } + } + + fn parse_number(&mut self) -> Result { + let start = self.position; + if self.peek_byte() == Some(b'-') { + self.position += 1; + } + + match self.peek_byte() { + Some(b'0') => { + self.position += 1; + if matches!(self.peek_byte(), Some(b'0'..=b'9')) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + } + Some(b'1'..=b'9') => self.consume_digits(), + _ => return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson), + } + + if self.peek_byte() == Some(b'.') { + self.position += 1; + if !matches!(self.peek_byte(), Some(b'0'..=b'9')) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.consume_digits(); + } + + if matches!(self.peek_byte(), Some(b'e' | b'E')) { + self.position += 1; + if matches!(self.peek_byte(), Some(b'+' | b'-')) { + self.position += 1; + } + if !matches!(self.peek_byte(), Some(b'0'..=b'9')) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.consume_digits(); + } + + Ok(self.input[start..self.position].to_owned()) + } + + fn consume_digits(&mut self) { + while matches!(self.peek_byte(), Some(b'0'..=b'9')) { + self.position += 1; + } + } + + fn parse_literal( + &mut self, + literal: &[u8], + ) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + let end = self.position + literal.len(); + if self.input.as_bytes().get(self.position..end) != Some(literal) { + return Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson); + } + self.position = end; + Ok(()) + } + + fn skip_whitespace(&mut self) { + while matches!(self.peek_byte(), Some(b' ' | b'\t' | b'\r' | b'\n')) { + self.position += 1; + } + } + + fn expect_byte(&mut self, expected: u8) -> Result<(), WebDriverBiDiResponseEnvelopeParseError> { + if self.peek_byte() == Some(expected) { + self.position += 1; + Ok(()) + } else { + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson) + } + } + + fn peek_byte(&self) -> Option { + self.input.as_bytes().get(self.position).copied() + } +} diff --git a/crates/originweave-core/src/webdriver_bidi_result.rs b/crates/originweave-core/src/webdriver_bidi_result.rs new file mode 100644 index 000000000..ba3288269 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_result.rs @@ -0,0 +1,188 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use crate::{ + BrowserAuthorityRegistry, BrowserContextOriginEpochDispatchTarget, BrowserProtocolCapability, + BrowserProtocolKind, ObservedNodeHandle, ValidatedBrowserProtocolUse, + ValidatedWebDriverBiDiLocateNodesResponse, WebDriverBiDiAccessibilityQueryError, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiRemoteNodeReferenceError, +}; + +/// Fail-closed errors while admitting one correlated `locateNodes` result batch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiLocateNodesResultAdmissionError { + /// The returned node count exceeded the exact serialized command budget. + Query(WebDriverBiDiAccessibilityQueryError), + /// One returned item was not an admissible WebDriver BiDi node remote value. + RemoteNode(WebDriverBiDiRemoteNodeReferenceError), +} + +impl Display for WebDriverBiDiLocateNodesResultAdmissionError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Query(error) => write!( + formatter, + "correlated locateNodes result violated the exact command budget: {error}" + ), + Self::RemoteNode(error) => write!( + formatter, + "correlated locateNodes result contained an inadmissible remote node: {error}" + ), + } + } +} + +impl Error for WebDriverBiDiLocateNodesResultAdmissionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Query(error) => Some(error), + Self::RemoteNode(error) => Some(error), + } + } +} + +/// Non-cloneable evidence for one correlated, bounded, structurally admitted `locateNodes` result. +/// +/// Construction consumes exact command-correlation evidence, validates the returned array length +/// against the exact `maxNodeCount` serialized by that command, and normalizes every returned item +/// through [`WebDriverBiDiRemoteNodeReference`]. The resulting batch therefore cannot be reused +/// with a different ambient query budget and cannot retain non-node remote values or unusable node +/// identifiers. +/// +/// This is still transport evidence, not OriginWeave node authority. It does not parse raw JSON, +/// authenticate Chromium or its adapter, prove current session/context/origin/document authority, +/// mint [`crate::ObservedNodeHandle`] values, authorize policy or typed input, or establish an Agent +/// action. A later reviewed current-authority boundary must consume these normalized references. +#[derive(Debug, PartialEq, Eq)] +pub struct ValidatedWebDriverBiDiLocateNodesResult { + correlated: ValidatedWebDriverBiDiLocateNodesResponse, + nodes: Vec, +} + +impl ValidatedWebDriverBiDiLocateNodesResult { + /// Return the exact command identifier proven to own this result batch. + #[must_use] + pub const fn command_id(&self) -> u64 { + self.correlated.command_id() + } + + /// Return the bounded browsing-context identifier serialized by the correlated command. + #[must_use] + pub fn browsing_context(&self) -> &str { + self.correlated.browsing_context() + } + + /// Return the exact `maxNodeCount` serialized by the correlated command. + #[must_use] + pub const fn max_node_count(&self) -> u16 { + self.correlated.max_node_count() + } + + /// Return the normalized untrusted node references admitted from the result array. + #[must_use] + pub fn nodes(&self) -> &[WebDriverBiDiRemoteNodeReference] { + &self.nodes + } + + /// Consume this correlated result and bind its nodes to exact current browser authority. + /// + /// The consumed protocol-use proof must be WebDriver BiDi SemanticObservation authority. The + /// exact browsing-context identifier serialized by the correlated command must still map to + /// the supplied OriginWeave context; this check is read-only and never registers a missing or + /// different context. The registry then revalidates the exact session, canonical origin, and + /// document epoch before all normalized `sharedId` values are bound transactionally. + /// + /// Success mints only [`ObservedNodeHandle`] values. It does not authenticate Chromium or an + /// adapter process, perform browser I/O, authorize policy or typed input, or turn descriptive + /// protocol evidence into an Agent capability. + pub fn bind_current_nodes( + self, + validated: ValidatedBrowserProtocolUse, + authority_registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'_>, + ) -> Result, WebDriverBiDiLocateNodesAdmissionError> { + if validated.kind() != BrowserProtocolKind::WebDriverBiDi { + return Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind(validated.kind()), + ); + } + if validated.capability() != BrowserProtocolCapability::SemanticObservation { + return Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + validated.capability(), + ), + ); + } + let _consumed_observation_proof = validated; + let context_origin = target.context_origin(); + let context = context_origin.context(); + authority_registry + .require_context_external_identifier( + context.browser_session(), + context.browsing_context(), + self.browsing_context(), + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + let current_epoch = authority_registry + .require_context_origin( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + if current_epoch != target.expected_epoch() { + return Err( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected: target.expected_epoch(), + current: current_epoch, + }, + ); + } + + let shared_ids = self + .nodes + .iter() + .map(WebDriverBiDiRemoteNodeReference::shared_id) + .collect::>(); + authority_registry + .bind_nodes( + context.browser_session(), + context.browsing_context(), + context_origin.expected_origin(), + &shared_ids, + ) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority) + } +} + +impl ValidatedWebDriverBiDiLocateNodesResponse { + /// Consume exact command-correlation evidence and admit one structured `locateNodes` result. + /// + /// The result count is checked before any item is normalized so an over-budget response fails + /// at the resource boundary even when its individual elements are malformed. Every in-budget + /// item must then be the exact WebDriver BiDi `node` remote-value type and carry a usable + /// `sharedId`. Success consumes the correlation evidence, preventing the same command response + /// from being admitted repeatedly or against a different result payload. + pub fn admit_result_nodes( + self, + items: &[(&str, Option<&str>)], + ) -> Result + { + self.validate_result_count(items.len()) + .map_err(WebDriverBiDiLocateNodesResultAdmissionError::Query)?; + + let mut nodes = Vec::with_capacity(items.len()); + for (remote_type, shared_id) in items { + nodes.push( + WebDriverBiDiRemoteNodeReference::new(remote_type, *shared_id) + .map_err(WebDriverBiDiLocateNodesResultAdmissionError::RemoteNode)?, + ); + } + + Ok(ValidatedWebDriverBiDiLocateNodesResult { + correlated: self, + nodes, + }) + } +} diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs b/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs new file mode 100644 index 000000000..5002731db --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_websocket_connect_target.rs @@ -0,0 +1,206 @@ +//! Explicit no-DNS connection targets for correlated WebDriver BiDi endpoints. +//! +//! This boundary converts only literal loopback listener identities into exact socket metadata. +//! It deliberately refuses `localhost` so a later connector cannot silently inherit ambient DNS +//! authority from an admitted WebDriver endpoint. When explicit trusted name resolution is needed, +//! the typed error preserves the correlated endpoint instead of discarding its session evidence. +//! A separately observed connected peer must also match the approved socket destination exactly +//! before it becomes verified transport metadata. These values do not open a socket, authenticate +//! a process, negotiate TLS, perform a WebSocket handshake, or grant Agent authority. + +use std::{ + fmt, + net::{Ipv4Addr, Ipv6Addr, SocketAddr}, +}; + +use crate::CorrelatedWebDriverBiDiWebSocketEndpoint; + +/// An exact loopback socket destination derived from one correlated WebDriver BiDi endpoint. +/// +/// The destination is inert connection metadata. It proves only that the already-admitted endpoint +/// named a literal loopback IP address, retained an explicit nonzero port, and was correlated to the +/// expected WebDriver session id. A runtime connector must independently establish a connection and +/// verify its observed peer before treating that transport as the approved destination. TLS, +/// WebSocket, process, policy, and browser authority remain separate boundaries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebDriverBiDiWebSocketConnectTarget { + socket_addr: SocketAddr, + requires_tls: bool, + session_id: String, +} + +impl WebDriverBiDiWebSocketConnectTarget { + /// Return the exact loopback socket destination without performing name resolution. + #[must_use] + pub const fn socket_addr(&self) -> SocketAddr { + self.socket_addr + } + + /// Return whether the admitted endpoint requires a TLS-protected WebSocket transport. + #[must_use] + pub const fn requires_tls(&self) -> bool { + self.requires_tls + } + + /// Return the exact WebDriver session id established by the preceding correlation boundary. + #[must_use] + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// Consume this approved destination and verify one observed connected socket peer exactly. + /// + /// Matching requires the complete [`SocketAddr`]—IP address and port—to equal the approved + /// no-DNS destination. A mismatch consumes the target and fails closed, preventing a connector + /// from accidentally reusing the same authority after observing a different peer. Success + /// produces inert verified-peer metadata only; it does not authenticate an OS process, + /// negotiate TLS, perform a WebSocket handshake, or grant browser/Agent authority. + pub fn verify_connected_peer( + self, + observed_peer: SocketAddr, + ) -> Result { + let expected = self.socket_addr; + if observed_peer != expected { + return Err(WebDriverBiDiSocketPeerVerificationError::PeerMismatch { + expected, + actual: observed_peer, + }); + } + + Ok(VerifiedWebDriverBiDiSocketPeer { + connect_target: self, + }) + } +} + +/// Inert metadata proving that a connected peer exactly matched the approved BiDi destination. +/// +/// This value carries only the destination, TLS requirement, and correlated WebDriver session id +/// already established by preceding boundaries. It does not prove process identity, TLS peer +/// identity, WebSocket protocol state, browser authenticity, policy authorization, or Agent action +/// authority. +#[derive(Debug, PartialEq, Eq)] +pub struct VerifiedWebDriverBiDiSocketPeer { + connect_target: WebDriverBiDiWebSocketConnectTarget, +} + +impl VerifiedWebDriverBiDiSocketPeer { + /// Return the exact approved and observed socket peer address. + #[must_use] + pub const fn socket_addr(&self) -> SocketAddr { + self.connect_target.socket_addr() + } + + /// Return whether the correlated endpoint still requires TLS before WebSocket use. + #[must_use] + pub const fn requires_tls(&self) -> bool { + self.connect_target.requires_tls() + } + + /// Return the exact correlated WebDriver session id. + #[must_use] + pub fn session_id(&self) -> &str { + self.connect_target.session_id() + } +} + +/// Fail-closed errors while verifying an observed BiDi socket peer. +#[derive(Debug, PartialEq, Eq)] +pub enum WebDriverBiDiSocketPeerVerificationError { + /// The connected peer differed from the exact destination approved before connection. + PeerMismatch { + /// Exact socket address that the connector was authorized to reach. + expected: SocketAddr, + /// Socket peer address observed after connection. + actual: SocketAddr, + }, +} + +impl fmt::Display for WebDriverBiDiSocketPeerVerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PeerMismatch { .. } => formatter.write_str( + "connected WebDriver BiDi socket peer does not match the approved destination", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiSocketPeerVerificationError {} + +impl CorrelatedWebDriverBiDiWebSocketEndpoint { + /// Consume this correlated endpoint and derive one exact no-DNS loopback socket destination. + /// + /// Literal IPv4 and IPv6 loopback hosts become an exact [`SocketAddr`]. Any admitted host that + /// is not an IP literal—including `localhost`—fails closed so the caller must perform an + /// explicit, separately trusted name-resolution step rather than inheriting ambient resolver + /// authority. The name-resolution-required error retains this correlated endpoint so that + /// trusted resolver handoff does not require reconstructing or recorrelation of session evidence. + /// This method performs no DNS lookup, socket I/O, peer authentication, TLS, or WebSocket + /// handshake. + pub fn into_explicit_connect_target( + self, + ) -> Result { + let socket_addr = if let Ok(ipv4) = self.host().parse::() { + SocketAddr::from((ipv4, self.port())) + } else if let Ok(ipv6) = self.host().parse::() { + SocketAddr::from((ipv6, self.port())) + } else { + return Err( + WebDriverBiDiWebSocketConnectTargetError::NameResolutionRequired { + correlated_endpoint: self, + }, + ); + }; + + Ok(WebDriverBiDiWebSocketConnectTarget { + socket_addr, + requires_tls: self.is_secure(), + session_id: self.session_id().to_owned(), + }) + } +} + +/// Fail-closed errors while deriving an explicit WebDriver BiDi socket destination. +#[derive(Debug, PartialEq, Eq)] +pub enum WebDriverBiDiWebSocketConnectTargetError { + /// The admitted endpoint used a host name and therefore requires explicit trusted resolution. + NameResolutionRequired { + /// The still-correlated endpoint that must be handed to a separately trusted resolver. + correlated_endpoint: CorrelatedWebDriverBiDiWebSocketEndpoint, + }, +} + +impl WebDriverBiDiWebSocketConnectTargetError { + /// Borrow the correlated endpoint preserved for an explicit trusted resolver handoff. + #[must_use] + pub const fn correlated_endpoint(&self) -> &CorrelatedWebDriverBiDiWebSocketEndpoint { + match self { + Self::NameResolutionRequired { + correlated_endpoint, + } => correlated_endpoint, + } + } + + /// Recover the correlated endpoint for an explicit trusted resolver handoff. + #[must_use] + pub fn into_correlated_endpoint(self) -> CorrelatedWebDriverBiDiWebSocketEndpoint { + match self { + Self::NameResolutionRequired { + correlated_endpoint, + } => correlated_endpoint, + } + } +} + +impl fmt::Display for WebDriverBiDiWebSocketConnectTargetError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NameResolutionRequired { .. } => formatter.write_str( + "WebDriver BiDi WebSocket endpoint requires explicit trusted name resolution", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiWebSocketConnectTargetError {} diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs new file mode 100644 index 000000000..83d2e9b04 --- /dev/null +++ b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs @@ -0,0 +1,327 @@ +use std::fmt; +use std::net::{Ipv4Addr, Ipv6Addr}; + +/// Maximum admitted bytes for one WebDriver BiDi WebSocket endpoint. +/// +/// This is an OriginWeave first-Chromium-fixture safety budget, not a +/// WebDriver BiDi protocol maximum. +pub const MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES: usize = 2_048; + +/// One bounded canonical WebDriver BiDi session WebSocket endpoint. +/// +/// This value is transport metadata only. Construction does not authenticate +/// Chromium, ChromeDriver, the operating-system peer, TLS, policy, or Agent +/// authority. The first real-Chromium fixture intentionally admits only +/// loopback listener identities; the connection boundary must still verify the +/// actual peer before exposing transport I/O. +#[derive(Debug, PartialEq, Eq)] +pub struct WebDriverBiDiWebSocketEndpoint { + endpoint: String, + secure: bool, + host: String, + port: u16, + session_id: String, +} + +/// One admitted WebDriver BiDi WebSocket endpoint correlated to an expected session id. +/// +/// Correlation proves only that the endpoint resource and the caller-supplied expected session id +/// contain the same canonical admitted session text. The expected session id must itself come from +/// a trusted session-creation boundary. This value does not authenticate Chromium, ChromeDriver, +/// the caller, the operating-system peer, TLS, policy, or Agent authority, and it does not establish +/// a socket. +#[derive(Debug, PartialEq, Eq)] +pub struct CorrelatedWebDriverBiDiWebSocketEndpoint { + endpoint: WebDriverBiDiWebSocketEndpoint, +} + +impl WebDriverBiDiWebSocketEndpoint { + /// Admit one bounded canonical first-fixture WebDriver BiDi endpoint. + pub fn new(value: &str) -> Result { + if value.is_empty() { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::EmptyEndpoint); + } + if value.len() > MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::EndpointTooLong); + } + if value.bytes().any(|byte| !byte.is_ascii_graphic()) { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText); + } + if value.bytes().any(|byte| matches!(byte, b'?' | b'#')) { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden); + } + + let (secure, remainder) = if let Some(remainder) = value.strip_prefix("ws://") { + (false, remainder) + } else if let Some(remainder) = value.strip_prefix("wss://") { + (true, remainder) + } else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidScheme); + }; + + let Some(path_start) = remainder.find('/') else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource); + }; + let authority = &remainder[..path_start]; + let resource = &remainder[path_start..]; + if authority.is_empty() || authority.contains('@') { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + + let (host, port_text) = if let Some(bracketed) = authority.strip_prefix('[') { + let Some(close) = bracketed.find(']') else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + }; + let host_text = &bracketed[..close]; + let suffix = &bracketed[close + 1..]; + let Some(port_text) = suffix.strip_prefix(':') else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + }; + if port_text.is_empty() { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + let Ok(ip) = host_text.parse::() else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + }; + if !ip.is_loopback() { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost); + } + if ip.to_string() != host_text { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + (host_text.to_owned(), port_text) + } else { + let Some((host_text, port_text)) = authority.rsplit_once(':') else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + }; + if host_text.is_empty() || port_text.is_empty() || host_text.contains(':') { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + if host_text == "localhost" { + (host_text.to_owned(), port_text) + } else if let Ok(ip) = host_text.parse::() { + if !ip.is_loopback() { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost); + } + (host_text.to_owned(), port_text) + } else if host_text + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) + { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost); + } else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority); + } + }; + + let Ok(port) = port_text.parse::() else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidPort); + }; + if port == 0 || port.to_string() != port_text { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidPort); + } + + let Some(session_id) = resource.strip_prefix("/session/") else { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource); + }; + if session_id.is_empty() || session_id.contains('/') { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource); + } + if !is_canonical_session_id(session_id) { + return Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionId); + } + + Ok(Self { + endpoint: value.to_owned(), + secure, + host, + port, + session_id: session_id.to_owned(), + }) + } + + /// Correlate this endpoint resource to one exact expected WebDriver session id. + /// + /// The endpoint is consumed so downstream connection code can require the correlated type and + /// cannot accidentally retain an uncorrelated copy. This comparison does not establish that the + /// caller-supplied expected id is authentic; the caller must obtain that id from its trusted + /// session-creation boundary. + pub fn correlate_session_id( + self, + expected_session_id: &str, + ) -> Result< + CorrelatedWebDriverBiDiWebSocketEndpoint, + WebDriverBiDiWebSocketEndpointCorrelationError, + > { + if !is_canonical_session_id(expected_session_id) { + return Err(WebDriverBiDiWebSocketEndpointCorrelationError::InvalidExpectedSessionId); + } + if self.session_id != expected_session_id { + return Err(WebDriverBiDiWebSocketEndpointCorrelationError::SessionIdMismatch); + } + Ok(CorrelatedWebDriverBiDiWebSocketEndpoint { endpoint: self }) + } + + /// Return the exact admitted endpoint text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.endpoint + } + + /// Return whether the endpoint uses `wss` rather than `ws`. + #[must_use] + pub const fn is_secure(&self) -> bool { + self.secure + } + + /// Return the canonical loopback listener host without IPv6 brackets. + #[must_use] + pub fn host(&self) -> &str { + &self.host + } + + /// Return the explicit nonzero listener port. + #[must_use] + pub const fn port(&self) -> u16 { + self.port + } + + /// Return the exact canonical session identifier admitted from the WebDriver endpoint. + #[must_use] + pub fn session_id(&self) -> &str { + &self.session_id + } +} + +impl CorrelatedWebDriverBiDiWebSocketEndpoint { + /// Return the exact admitted endpoint text. + #[must_use] + pub fn as_str(&self) -> &str { + self.endpoint.as_str() + } + + /// Return whether the endpoint uses `wss` rather than `ws`. + #[must_use] + pub const fn is_secure(&self) -> bool { + self.endpoint.is_secure() + } + + /// Return the canonical loopback listener host without IPv6 brackets. + #[must_use] + pub fn host(&self) -> &str { + self.endpoint.host() + } + + /// Return the explicit nonzero listener port. + #[must_use] + pub const fn port(&self) -> u16 { + self.endpoint.port() + } + + /// Return the exact session id proven equal to the caller-supplied expected session id. + #[must_use] + pub fn session_id(&self) -> &str { + self.endpoint.session_id() + } +} + +fn is_lowercase_hex(byte: u8) -> bool { + byte.is_ascii_digit() || matches!(byte, b'a'..=b'f') +} + +fn is_canonical_session_id(value: &str) -> bool { + let bytes = value.as_bytes(); + match bytes.len() { + 32 => bytes.iter().copied().all(is_lowercase_hex), + 36 => { + for (index, byte) in bytes.iter().copied().enumerate() { + let valid = if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + is_lowercase_hex(byte) + }; + if !valid { + return false; + } + } + true + } + _ => false, + } +} + +/// Fail-closed admission errors for WebDriver BiDi WebSocket endpoint metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiWebSocketEndpointAdmissionError { + /// The endpoint text is empty. + EmptyEndpoint, + /// The endpoint text exceeds the OriginWeave safety budget. + EndpointTooLong, + /// The endpoint contains non-ASCII, whitespace, or control text. + InvalidEndpointText, + /// The endpoint does not use the exact `ws` or `wss` scheme. + InvalidScheme, + /// Query or fragment data is present and therefore not part of the admitted session resource. + QueryOrFragmentForbidden, + /// The authority is absent, credential-bearing, ambiguous, or malformed. + InvalidAuthority, + /// The authority identifies a non-loopback host. + NonLoopbackHost, + /// The port is absent, zero, out of range, or not canonically serialized. + InvalidPort, + /// The path is not exactly one `/session/` resource. + InvalidSessionResource, + /// The session id is not an admitted canonical W3C/ChromeDriver representation. + InvalidSessionId, +} + +impl fmt::Display for WebDriverBiDiWebSocketEndpointAdmissionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::EmptyEndpoint => "WebDriver BiDi WebSocket endpoint is empty", + Self::EndpointTooLong => "WebDriver BiDi WebSocket endpoint exceeds the safety budget", + Self::InvalidEndpointText => { + "WebDriver BiDi WebSocket endpoint text is not canonical ASCII" + } + Self::InvalidScheme => "WebDriver BiDi WebSocket endpoint scheme is not ws or wss", + Self::QueryOrFragmentForbidden => { + "WebDriver BiDi WebSocket endpoint query or fragment is forbidden" + } + Self::InvalidAuthority => "WebDriver BiDi WebSocket endpoint authority is invalid", + Self::NonLoopbackHost => "WebDriver BiDi WebSocket endpoint host is not loopback", + Self::InvalidPort => "WebDriver BiDi WebSocket endpoint port is invalid", + Self::InvalidSessionResource => { + "WebDriver BiDi WebSocket endpoint session resource is invalid" + } + Self::InvalidSessionId => "WebDriver BiDi WebSocket endpoint session id is invalid", + }; + f.write_str(message) + } +} + +impl std::error::Error for WebDriverBiDiWebSocketEndpointAdmissionError {} + +/// Fail-closed errors while correlating an admitted endpoint with an expected session id. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebDriverBiDiWebSocketEndpointCorrelationError { + /// The expected session id is not an admitted canonical W3C/ChromeDriver representation. + InvalidExpectedSessionId, + /// The endpoint resource belongs to a different canonical session id. + SessionIdMismatch, +} + +impl fmt::Display for WebDriverBiDiWebSocketEndpointCorrelationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::InvalidExpectedSessionId => { + "expected WebDriver session id is not an admitted canonical representation" + } + Self::SessionIdMismatch => { + "WebDriver BiDi WebSocket endpoint session id does not match the expected session" + } + }; + f.write_str(message) + } +} + +impl std::error::Error for WebDriverBiDiWebSocketEndpointCorrelationError {} diff --git a/crates/originweave-core/tests/browser_authority_registry.rs b/crates/originweave-core/tests/browser_authority_registry.rs new file mode 100644 index 000000000..339fde349 --- /dev/null +++ b/crates/originweave-core/tests/browser_authority_registry.rs @@ -0,0 +1,393 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, NodeHandleError, + ObservedNodeHandle, Origin, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiRemoteNodeReferenceError, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn loopback_origin() -> Origin { + Origin::parse("http://127.0.0.1:43127").expect("valid loopback fixture origin") +} + +fn semantic_observation_proof() -> ValidatedBrowserProtocolUse { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + ) + .expect("valid semantic-observation descriptor"); + descriptor + .validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + ) + .expect("valid semantic-observation proof") +} + +fn bind_observed_node( + registry: &mut BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifier: &str, +) -> Result { + let epoch = registry + .bind_context_origin(browser_session, browsing_context, origin) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("generic"), None, 1) + .expect("valid bounded semantic-node query"); + query + .bind_current_nodes( + semantic_observation_proof(), + registry, + target, + &[("node", Some(external_identifier))], + )? + .into_iter() + .next() + .ok_or(WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + )) +} + +#[test] +fn external_protocol_identifiers_are_scoped_and_never_become_authority() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + + let first_session = registry.register_session("webdriver-session-A")?; + let repeated_session = registry.register_session("webdriver-session-A")?; + let second_session = registry.register_session("webdriver-session-B")?; + + assert_eq!(first_session, repeated_session); + assert_ne!(first_session, second_session); + + let first_context = registry.register_context(first_session, "frame-root")?; + let repeated_context = registry.register_context(first_session, "frame-root")?; + let second_context = registry.register_context(second_session, "frame-root")?; + + assert_eq!(first_context, repeated_context); + assert_ne!(first_context, second_context); + assert_eq!( + registry.current_epoch(first_context)?, + DocumentEpoch::new(1)? + ); + Ok(()) +} + +#[test] +fn public_default_and_error_contracts_are_usable_from_an_adapter() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::default(); + assert!(registry.register_session("adapter-session")?.value() > 0); + + let first_session = BrowserSessionId::new(1)?; + let second_session = BrowserSessionId::new(2)?; + let cases = [ + ( + BrowserRegistryError::InvalidExternalIdentifier, + "external browser identifier must contain 1 to 512 UTF-8 bytes without control, whitespace, or Unicode format characters".to_owned(), + ), + ( + BrowserRegistryError::UnknownBrowserSession, + "browser session is not registered in this authority registry".to_owned(), + ), + ( + BrowserRegistryError::UnknownBrowsingContext, + "browsing context is not registered in this authority registry".to_owned(), + ), + ( + BrowserRegistryError::ContextSessionMismatch { + expected: first_session, + actual: second_session, + }, + "browsing context belongs to session 1, not session 2".to_owned(), + ), + ( + BrowserRegistryError::OriginChangedWithoutDocumentAdvance, + "browsing context origin changed without advancing the document epoch".to_owned(), + ), + ( + BrowserRegistryError::IdentifierSpaceExhausted, + "browser authority identifier space is exhausted".to_owned(), + ), + ( + BrowserRegistryError::DocumentEpochExhausted, + "browser document epoch space is exhausted".to_owned(), + ), + ( + BrowserRegistryError::InternalAuthorityInvariant, + "browser authority registry violated a nonzero invariant".to_owned(), + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + } + Ok(()) +} + +#[test] +fn document_rotation_invalidates_old_external_node_bindings() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = loopback_origin(); + + let first = bind_observed_node(&mut registry, session, context, &origin, "backend-node-17")?; + let same = bind_observed_node(&mut registry, session, context, &origin, "backend-node-17")?; + assert_eq!(first.node_id(), same.node_id()); + + let next_epoch = registry.advance_document(context)?; + assert_eq!(next_epoch.value(), 2); + assert_eq!( + first.validate_current(session, context, &origin, next_epoch), + Err(NodeHandleError::StaleDocumentEpoch { + observed: first.document_epoch(), + current: next_epoch, + }) + ); + + let rebound = bind_observed_node(&mut registry, session, context, &origin, "backend-node-17")?; + assert_eq!(rebound.document_epoch(), next_epoch); + assert_ne!(first.node_id(), rebound.node_id()); + Ok(()) +} + +#[test] +fn retired_context_and_session_authority_cannot_be_reused() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = loopback_origin(); + let first_node = + bind_observed_node(&mut registry, session, context, &origin, "backend-node-17")?; + + registry.remove_context(context)?; + assert_eq!( + registry.current_epoch(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + bind_observed_node(&mut registry, session, context, &origin, "backend-node-17"), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::UnknownBrowsingContext + )) + ); + assert_eq!( + registry.remove_context(context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + + let replacement_context = registry.register_context(session, "top-level-context")?; + assert_ne!(replacement_context, context); + let replacement_node = bind_observed_node( + &mut registry, + session, + replacement_context, + &origin, + "backend-node-17", + )?; + assert_ne!(replacement_node.node_id(), first_node.node_id()); + + registry.remove_session(session)?; + assert_eq!( + registry.register_context(session, "after-session-retirement"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + assert_eq!( + registry.current_epoch(replacement_context), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + assert_eq!( + registry.remove_session(session), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let replacement_session = registry.register_session("webdriver-session")?; + assert_ne!(replacement_session, session); + let next_context = registry.register_context(replacement_session, "top-level-context")?; + assert_ne!(next_context, replacement_context); + Ok(()) +} + +#[test] +fn context_cannot_be_reused_by_another_session() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "shared-looking-context")?; + let origin = loopback_origin(); + + assert_eq!( + bind_observed_node(&mut registry, attacker, context, &origin, "node"), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + } + )) + ); + Ok(()) +} + +#[test] +fn context_origin_cannot_change_without_document_rotation() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let first_origin = loopback_origin(); + let second_origin = + Origin::parse("http://localhost:43127").expect("valid loopback fixture origin"); + + bind_observed_node( + &mut registry, + session, + context, + &first_origin, + "backend-node-17", + )?; + assert_eq!( + bind_observed_node( + &mut registry, + session, + context, + &second_origin, + "backend-node-18" + ), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::OriginChangedWithoutDocumentAdvance + )) + ); + Ok(()) +} + +#[test] +fn external_identifiers_are_bounded_without_assuming_protocol_syntax() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + + assert_eq!( + registry.register_session(""), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session(&"x".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1)), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let unicode = registry.register_session("세션-opaque-✓")?; + assert!(unicode.value() > 0); + + assert_eq!( + registry.register_session(" "), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session("webdriver-session\n"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session("webdriver-session\u{0000}"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session("webdriver-session\u{200B}"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + assert_eq!( + registry.register_session("webdriver-session\u{202E}"), + Err(BrowserRegistryError::InvalidExternalIdentifier) + ); + + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = loopback_origin(); + assert_eq!( + bind_observed_node( + &mut registry, + session, + context, + &origin, + "backend-node-17\n", + ), + Err(WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId + )) + ); + Ok(()) +} + +#[test] +fn authority_identifier_capacity_is_bounded_and_testable() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let session = registry.register_session("session-one")?; + assert_eq!( + registry.register_session("session-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let context = registry.register_context(session, "context-one")?; + assert_eq!( + registry.register_context(session, "context-two"), + Err(BrowserRegistryError::IdentifierSpaceExhausted) + ); + + let origin = loopback_origin(); + assert!(bind_observed_node(&mut registry, session, context, &origin, "node-one").is_ok()); + assert_eq!( + bind_observed_node(&mut registry, session, context, &origin, "node-two"), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::IdentifierSpaceExhausted + )) + ); + Ok(()) +} + +#[test] +fn unknown_internal_authority_is_rejected_before_node_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let unknown = BrowserSessionId::new(999)?; + + assert_eq!( + registry.register_context(unknown, "context"), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let known = registry.register_session("known-session")?; + let context = registry.register_context(known, "known-context")?; + let origin = loopback_origin(); + assert_eq!( + bind_observed_node(&mut registry, unknown, context, &origin, "node"), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::UnknownBrowserSession + )) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_context_origin_binding.rs b/crates/originweave-core/tests/browser_context_origin_binding.rs new file mode 100644 index 000000000..9f79cb652 --- /dev/null +++ b/crates/originweave-core/tests/browser_context_origin_binding.rs @@ -0,0 +1,158 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; +use std::io; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiRemoteNodeReferenceError, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn first_origin() -> Result> { + Origin::parse("http://127.0.0.1:43127") + .map_err(|_error| io::Error::other("controlled first origin must be valid").into()) +} + +fn second_origin() -> Result> { + Origin::parse("http://localhost:43127") + .map_err(|_error| io::Error::other("controlled second origin must be valid").into()) +} + +fn semantic_observation_proof() -> ValidatedBrowserProtocolUse { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + ) + .expect("valid semantic-observation descriptor"); + descriptor + .validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + ) + .expect("valid semantic-observation proof") +} + +fn bind_observed_node( + registry: &mut BrowserAuthorityRegistry, + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, + origin: &Origin, + external_identifier: &str, +) -> Result { + let epoch = registry + .require_context_origin(browser_session, browsing_context, origin) + .map_err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(browser_session, browsing_context), + origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("generic"), None, 1) + .expect("valid bounded semantic-node query"); + query + .bind_current_nodes( + semantic_observation_proof(), + registry, + target, + &[("node", Some(external_identifier))], + )? + .into_iter() + .next() + .ok_or(WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + )) +} + +#[test] +fn context_origin_can_be_bound_before_node_discovery() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = first_origin()?; + + let epoch = registry.bind_context_origin(session, context, &origin)?; + assert_eq!(epoch, DocumentEpoch::new(1)?); + assert_eq!( + registry.bind_context_origin(session, context, &origin)?, + epoch + ); + + let node = bind_observed_node(&mut registry, session, context, &origin, "backend-node-17")?; + assert_eq!(node.document_epoch(), epoch); + assert_eq!(node.origin(), &origin); + Ok(()) +} + +#[test] +fn context_origin_change_requires_document_rotation() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let first = first_origin()?; + let second = second_origin()?; + + registry.bind_context_origin(session, context, &first)?; + assert_eq!( + registry.bind_context_origin(session, context, &second), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + + let next_epoch = registry.advance_document(context)?; + assert_eq!(next_epoch, DocumentEpoch::new(2)?); + assert_eq!( + registry.bind_context_origin(session, context, &second)?, + next_epoch + ); + Ok(()) +} + +#[test] +fn context_origin_binding_rejects_cross_session_and_unknown_authority() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "top-level-context")?; + let origin = first_origin()?; + + assert_eq!( + registry.bind_context_origin(attacker, context, &origin), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) + ); + + let unknown_session = BrowserSessionId::new(999)?; + assert_eq!( + registry.bind_context_origin(unknown_session, context, &origin), + Err(BrowserRegistryError::UnknownBrowserSession) + ); + + let unknown_context = BrowsingContextId::new(999)?; + assert_eq!( + registry.bind_context_origin(owner, unknown_context, &origin), + Err(BrowserRegistryError::UnknownBrowsingContext) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs new file mode 100644 index 000000000..da3bf51db --- /dev/null +++ b/crates/originweave-core/tests/browser_context_origin_epoch_protocol_dispatch.rs @@ -0,0 +1,214 @@ +use std::{cell::Cell, error::Error, io}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolRuntimeMetadata, DocumentEpoch, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type DispatchOutcome = Result<(u64, BrowserProtocolCapability), &'static str>; +type DispatchFn = fn(ValidatedBrowserProtocolUse, DocumentEpoch) -> DispatchOutcome; + +thread_local! { + static DISPATCH_CALLED: Cell = const { Cell::new(false) }; +} + +fn descriptor() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::TypedInput], + )?) +} + +fn runtime_metadata() -> BrowserProtocolRuntimeMetadata<'static> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn origin(value: &str) -> Result> { + Origin::parse(value).map_err(|_| { + Box::new(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid controlled origin fixture", + )) as Box + }) +} + +fn reset_dispatch_marker() { + DISPATCH_CALLED.with(|called| called.set(false)); +} + +fn dispatch_was_called() -> bool { + DISPATCH_CALLED.with(Cell::get) +} + +fn successful_dispatch( + validated: ValidatedBrowserProtocolUse, + current_epoch: DocumentEpoch, +) -> DispatchOutcome { + DISPATCH_CALLED.with(|called| called.set(true)); + Ok((current_epoch.value(), validated.capability())) +} + +#[test] +fn exact_context_origin_epoch_and_protocol_metadata_gate_one_dispatch_call() +-> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + let expected_epoch = registry.bind_context_origin(session, context, &expected_origin)?; + let context_origin = BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ); + let target = BrowserContextOriginEpochDispatchTarget::new(context_origin, expected_epoch); + + assert_eq!(target.context_origin(), context_origin); + assert_eq!(target.expected_epoch(), expected_epoch); + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolCapability::TypedInput, + successful_dispatch as DispatchFn, + )?; + + assert!(dispatch_was_called()); + assert_eq!(result, Ok((1, BrowserProtocolCapability::TypedInput))); + Ok(()) +} + +#[test] +fn same_origin_new_document_epoch_fails_before_protocol_dispatch() -> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + let observed_epoch = registry.bind_context_origin(session, context, &expected_origin)?; + let context_origin = BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ); + let target = BrowserContextOriginEpochDispatchTarget::new(context_origin, observed_epoch); + + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin(session, context, &expected_origin)?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolCapability::TypedInput, + successful_dispatch as DispatchFn, + ); + let error = match result { + Err(error) => error, + Ok(_) => { + return Err(Box::new(io::Error::other( + "stale document epoch unexpectedly dispatched", + ))); + } + }; + + assert_eq!( + error, + BrowserContextProtocolDispatchError::DocumentEpochMismatch { + expected: observed_epoch, + current: current_epoch, + } + ); + assert_eq!( + error.to_string(), + "browser document epoch 2 no longer matches observed epoch 1" + ); + assert!(error.source().is_none()); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn epoch_dispatch_preserves_authority_and_protocol_failures_before_callback() +-> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let current_origin = origin("https://app.example")?; + let other_origin = origin("https://other.example")?; + let expected_epoch = registry.bind_context_origin(session, context, ¤t_origin)?; + + let wrong_origin_target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &other_origin, + ), + expected_epoch, + ); + reset_dispatch_marker(); + let authority_result = descriptor.dispatch_if_context_origin_epoch_current( + ®istry, + wrong_origin_target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolCapability::TypedInput, + successful_dispatch as DispatchFn, + ); + assert!(matches!( + authority_result, + Err(BrowserContextProtocolDispatchError::BrowserAuthority(_)) + )); + assert!(!dispatch_was_called()); + + let current_target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + ¤t_origin, + ), + expected_epoch, + ); + let drifted_runtime = BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + "originweave-bidi-v2", + PROTOCOL_REVISION, + BROWSER_REVISION, + ); + reset_dispatch_marker(); + let protocol_result = descriptor.dispatch_if_context_origin_epoch_current( + ®istry, + current_target, + ORIGINWEAVE_PROTOCOL_VERSION, + drifted_runtime, + BrowserProtocolCapability::TypedInput, + successful_dispatch as DispatchFn, + ); + assert!(matches!( + protocol_result, + Err(BrowserContextProtocolDispatchError::ProtocolValidation(_)) + )); + assert!(!dispatch_was_called()); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs new file mode 100644 index 000000000..986d4cdd4 --- /dev/null +++ b/crates/originweave-core/tests/browser_context_origin_protocol_dispatch.rs @@ -0,0 +1,180 @@ +use std::{cell::Cell, error::Error, io}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextProtocolDispatchError, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserProtocolRuntimeMetadata, + BrowserProtocolUseValidationError, BrowserRegistryError, DocumentEpoch, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type DispatchOutcome = Result<(u64, BrowserProtocolCapability), &'static str>; +type DispatchFn = fn(ValidatedBrowserProtocolUse, DocumentEpoch) -> DispatchOutcome; + +thread_local! { + static DISPATCH_CALLED: Cell = const { Cell::new(false) }; +} + +fn descriptor() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?) +} + +fn runtime_metadata(adapter_version: &str) -> BrowserProtocolRuntimeMetadata<'_> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + adapter_version, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn origin(value: &str) -> Result> { + Origin::parse(value).map_err(|_| { + Box::new(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid controlled origin fixture", + )) as Box + }) +} + +fn reset_dispatch_marker() { + DISPATCH_CALLED.with(|called| called.set(false)); +} + +fn dispatch_was_called() -> bool { + DISPATCH_CALLED.with(Cell::get) +} + +fn successful_dispatch( + validated: ValidatedBrowserProtocolUse, + current_epoch: DocumentEpoch, +) -> DispatchOutcome { + DISPATCH_CALLED.with(|called| called.set(true)); + Ok((current_epoch.value(), validated.capability())) +} + +#[test] +fn exact_current_origin_and_protocol_metadata_gate_one_dispatch_call() -> Result<(), Box> +{ + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + registry.bind_context_origin(session, context, &expected_origin)?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_origin_current( + ®istry, + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::SemanticObservation, + successful_dispatch as DispatchFn, + )?; + + assert!(dispatch_was_called()); + assert_eq!( + result, + Ok((1, BrowserProtocolCapability::SemanticObservation)) + ); + Ok(()) +} + +#[test] +fn origin_mismatch_or_unbound_origin_fails_before_dispatch() -> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + let other_origin = origin("https://other.example")?; + registry.bind_context_origin(session, context, &expected_origin)?; + + reset_dispatch_marker(); + assert_eq!( + descriptor.dispatch_if_context_origin_current( + ®istry, + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &other_origin, + ), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::SemanticObservation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::OriginChangedWithoutDocumentAdvance + )) + ); + assert!(!dispatch_was_called()); + + registry.advance_document(context)?; + reset_dispatch_marker(); + assert_eq!( + descriptor.dispatch_if_context_origin_current( + ®istry, + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::SemanticObservation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::ContextOriginNotBound + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn protocol_mismatch_after_origin_revalidation_still_prevents_dispatch() +-> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let expected_origin = origin("https://app.example")?; + registry.bind_context_origin(session, context, &expected_origin)?; + reset_dispatch_marker(); + + assert_eq!( + descriptor.dispatch_if_context_origin_current( + ®istry, + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &expected_origin, + ), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata("originweave-bidi-v2"), + BrowserProtocolCapability::SemanticObservation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::AdapterVersionMismatch + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_context_origin_revalidation.rs b/crates/originweave-core/tests/browser_context_origin_revalidation.rs new file mode 100644 index 000000000..7f35fab1d --- /dev/null +++ b/crates/originweave-core/tests/browser_context_origin_revalidation.rs @@ -0,0 +1,97 @@ +use std::error::Error; +use std::io; + +use originweave_core::{BrowserAuthorityRegistry, BrowserRegistryError, Origin}; + +fn first_origin() -> Result { + Origin::parse("http://127.0.0.1:43127") + .map_err(|_error| io::Error::other("controlled first origin must be valid")) +} + +fn second_origin() -> Result { + Origin::parse("http://localhost:43127") + .map_err(|_error| io::Error::other("controlled second origin must be valid")) +} + +#[test] +fn current_context_origin_must_be_bound_before_revalidation() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let origin = first_origin()?; + + assert_eq!( + registry.require_context_origin(session, context, &origin), + Err(BrowserRegistryError::ContextOriginNotBound) + ); + + let epoch = registry.bind_context_origin(session, context, &origin)?; + assert_eq!( + registry.require_context_origin(session, context, &origin), + Ok(epoch) + ); + Ok(()) +} + +#[test] +fn current_context_origin_revalidation_fails_closed_on_mismatch() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let first = first_origin()?; + let second = second_origin()?; + + registry.bind_context_origin(session, context, &first)?; + assert_eq!( + registry.require_context_origin(session, context, &second), + Err(BrowserRegistryError::OriginChangedWithoutDocumentAdvance) + ); + assert!( + registry + .require_context_origin(session, context, &first) + .is_ok() + ); + Ok(()) +} + +#[test] +fn document_rotation_requires_fresh_origin_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let first = first_origin()?; + let second = second_origin()?; + + registry.bind_context_origin(session, context, &first)?; + let next_epoch = registry.advance_document(context)?; + assert_eq!( + registry.require_context_origin(session, context, &first), + Err(BrowserRegistryError::ContextOriginNotBound) + ); + + registry.bind_context_origin(session, context, &second)?; + assert_eq!( + registry.require_context_origin(session, context, &second), + Ok(next_epoch) + ); + Ok(()) +} + +#[test] +fn context_origin_revalidation_preserves_session_ownership() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "top-level-context")?; + let origin = first_origin()?; + + registry.bind_context_origin(owner, context, &origin)?; + assert_eq!( + registry.require_context_origin(attacker, context, &origin), + Err(BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + }) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_context_protocol_dispatch.rs b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs new file mode 100644 index 000000000..333b28701 --- /dev/null +++ b/crates/originweave-core/tests/browser_context_protocol_dispatch.rs @@ -0,0 +1,234 @@ +use std::{cell::Cell, error::Error}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserRegistryError, + BrowserSessionId, BrowsingContextId, DocumentEpoch, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type DispatchOutcome = Result<(u64, BrowserProtocolCapability), &'static str>; +type DispatchFn = fn(ValidatedBrowserProtocolUse, DocumentEpoch) -> DispatchOutcome; + +thread_local! { + static DISPATCH_CALLED: Cell = const { Cell::new(false) }; +} + +fn descriptor() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::Navigation], + )?) +} + +fn runtime_metadata(adapter_version: &str) -> BrowserProtocolRuntimeMetadata<'_> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + adapter_version, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn target( + browser_session: BrowserSessionId, + browsing_context: BrowsingContextId, +) -> BrowserContextDispatchTarget { + BrowserContextDispatchTarget::new(browser_session, browsing_context) +} + +fn reset_dispatch_marker() { + DISPATCH_CALLED.with(|called| called.set(false)); +} + +fn dispatch_was_called() -> bool { + DISPATCH_CALLED.with(Cell::get) +} + +fn successful_dispatch( + validated: ValidatedBrowserProtocolUse, + current_epoch: DocumentEpoch, +) -> DispatchOutcome { + DISPATCH_CALLED.with(|called| called.set(true)); + Ok((current_epoch.value(), validated.capability())) +} + +#[test] +fn context_dispatch_target_preserves_requested_ids_without_granting_authority() +-> Result<(), Box> { + let session = BrowserSessionId::new(7)?; + let context = BrowsingContextId::new(11)?; + let target = target(session, context); + + assert_eq!(target.browser_session(), session); + assert_eq!(target.browsing_context(), context); + Ok(()) +} + +#[test] +fn exact_context_and_runtime_metadata_gate_one_dispatch_call() -> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_current( + ®istry, + target(session, context), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + )?; + + assert!(dispatch_was_called()); + assert_eq!(result, Ok((1, BrowserProtocolCapability::Navigation))); + + registry.advance_document(context)?; + reset_dispatch_marker(); + let next = descriptor.dispatch_if_context_current( + ®istry, + target(session, context), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + )?; + assert!(dispatch_was_called()); + assert_eq!(next, Ok((2, BrowserProtocolCapability::Navigation))); + Ok(()) +} + +#[test] +fn cross_session_context_reuse_fails_before_dispatch() -> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let owner = registry.register_session("owner-session")?; + let attacker = registry.register_session("attacker-session")?; + let context = registry.register_context(owner, "top-level-context")?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_current( + ®istry, + target(attacker, context), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + ); + + assert_eq!( + result, + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::ContextSessionMismatch { + expected: owner, + actual: attacker, + } + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn unknown_session_or_context_fails_before_dispatch() -> Result<(), Box> { + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let unknown_session = BrowserSessionId::new(999)?; + let unknown_context = BrowsingContextId::new(999)?; + + reset_dispatch_marker(); + assert_eq!( + descriptor.dispatch_if_context_current( + ®istry, + target(unknown_session, context), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::UnknownBrowserSession + )) + ); + assert!(!dispatch_was_called()); + + assert_eq!( + descriptor.dispatch_if_context_current( + ®istry, + target(session, unknown_context), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + ), + Err(BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::UnknownBrowsingContext + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn protocol_mismatch_after_context_validation_still_prevents_dispatch() -> Result<(), Box> +{ + let descriptor = descriptor()?; + let mut registry = BrowserAuthorityRegistry::new(); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_context_current( + ®istry, + target(session, context), + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata("originweave-bidi-v2"), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + ); + + assert_eq!( + result, + Err(BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::AdapterVersionMismatch + )) + ); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn context_protocol_dispatch_errors_preserve_typed_sources() { + let authority = BrowserContextProtocolDispatchError::BrowserAuthority( + BrowserRegistryError::UnknownBrowsingContext, + ); + assert!(authority.source().is_some()); + assert_eq!( + authority.to_string(), + "browser context authority denied protocol dispatch: browsing context is not registered in this authority registry" + ); + + let protocol = BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::AdapterVersionMismatch, + ); + assert!(protocol.source().is_some()); + assert_eq!( + protocol.to_string(), + "browser protocol validation denied context dispatch: runtime browser adapter version does not match the pinned adapter version" + ); +} diff --git a/crates/originweave-core/tests/browser_protocol_adapter.rs b/crates/originweave-core/tests/browser_protocol_adapter.rs new file mode 100644 index 000000000..5cf457a66 --- /dev/null +++ b/crates/originweave-core/tests/browser_protocol_adapter.rs @@ -0,0 +1,372 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolCapabilityRequirementError, BrowserProtocolDescriptorError, BrowserProtocolKind, + BrowserProtocolVersionRequirementError, MAX_BROWSER_PROTOCOL_METADATA_BYTES, + OriginWeaveProtocolVersion, +}; + +const CURRENT_ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const FUTURE_ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 2); +const BIDI_ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const BIDI_PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const CDP_ADAPTER_VERSION: &str = "originweave-cdp-v1"; +const CDP_PROTOCOL_REVISION: &str = "cdp-browser-r1639810"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +#[test] +fn originweave_protocol_version_is_explicit_and_canonical() { + assert_eq!(CURRENT_ORIGINWEAVE_PROTOCOL_VERSION.major(), 0); + assert_eq!(CURRENT_ORIGINWEAVE_PROTOCOL_VERSION.minor(), 1); + assert_eq!( + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION.to_string(), + "originweave/0.1" + ); + assert_ne!( + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + FUTURE_ORIGINWEAVE_PROTOCOL_VERSION + ); +} + +#[test] +fn webdriver_bidi_descriptor_is_explicit_and_capability_bounded() -> Result<(), Box> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[ + BrowserProtocolCapability::Navigation, + BrowserProtocolCapability::SemanticObservation, + BrowserProtocolCapability::TypedInput, + ], + )?; + + assert_eq!(descriptor.kind(), BrowserProtocolKind::WebDriverBiDi); + assert_eq!( + descriptor.originweave_protocol_version(), + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION + ); + assert_eq!(descriptor.adapter_version(), BIDI_ADAPTER_VERSION); + assert_eq!(descriptor.protocol_revision(), BIDI_PROTOCOL_REVISION); + assert_eq!(descriptor.browser_revision(), BROWSER_REVISION); + assert_eq!(descriptor.capability_count(), 3); + assert!(descriptor.supports(BrowserProtocolCapability::Navigation)); + assert!(descriptor.supports(BrowserProtocolCapability::SemanticObservation)); + assert!(descriptor.supports(BrowserProtocolCapability::TypedInput)); + assert!(!descriptor.supports(BrowserProtocolCapability::NetworkObservation)); + Ok(()) +} + +#[test] +fn cdp_capability_is_not_inferred_from_protocol_kind() -> Result<(), Box> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::ChromeDevToolsProtocol, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + CDP_ADAPTER_VERSION, + CDP_PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::NetworkObservation], + )?; + + assert_eq!(descriptor.protocol_revision(), CDP_PROTOCOL_REVISION); + assert!(descriptor.supports(BrowserProtocolCapability::NetworkObservation)); + assert!(!descriptor.supports(BrowserProtocolCapability::Navigation)); + assert!(!descriptor.supports(BrowserProtocolCapability::SemanticObservation)); + assert!(!descriptor.supports(BrowserProtocolCapability::TypedInput)); + Ok(()) +} + +#[test] +fn required_capability_fails_closed_without_side_effectful_fallback() -> Result<(), Box> +{ + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::Navigation], + )?; + + assert_eq!( + descriptor.require_capability(BrowserProtocolCapability::Navigation), + Ok(()) + ); + assert_eq!( + descriptor.require_capability(BrowserProtocolCapability::NetworkObservation), + Err( + BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + BrowserProtocolCapability::NetworkObservation, + ) + ) + ); + Ok(()) +} + +#[test] +fn required_originweave_protocol_version_fails_closed() -> Result<(), Box> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::Navigation], + )?; + + assert_eq!( + descriptor.require_originweave_protocol_version(CURRENT_ORIGINWEAVE_PROTOCOL_VERSION), + Ok(()) + ); + assert_eq!( + descriptor.require_originweave_protocol_version(FUTURE_ORIGINWEAVE_PROTOCOL_VERSION), + Err( + BrowserProtocolVersionRequirementError::ProtocolVersionMismatch { + required: FUTURE_ORIGINWEAVE_PROTOCOL_VERSION, + actual: CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + } + ) + ); + Ok(()) +} + +#[test] +fn malformed_or_ambiguous_metadata_fails_closed() { + let valid_capabilities = [BrowserProtocolCapability::Navigation]; + let invalid_adapter_versions = ["", " ", "bidi version", "bidi/version", "---", "비디"]; + for adapter_version in invalid_adapter_versions { + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + adapter_version, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidAdapterVersion) + ); + } + + let invalid_protocol_revisions = [ + "", + " ", + "webdriver bidi", + "webdriver/bidi", + "---", + "프로토콜", + ]; + for protocol_revision in invalid_protocol_revisions { + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + BIDI_ADAPTER_VERSION, + protocol_revision, + BROWSER_REVISION, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidProtocolRevision) + ); + } + + let invalid_browser_revisions = [ + "", + " ", + "chromium revision", + "chromium/revision", + "---", + "크로미움", + ]; + for browser_revision in invalid_browser_revisions { + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + browser_revision, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidBrowserRevision) + ); + } + + let oversized = "a".repeat(MAX_BROWSER_PROTOCOL_METADATA_BYTES + 1); + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + &oversized, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidAdapterVersion) + ); + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + BIDI_ADAPTER_VERSION, + &oversized, + BROWSER_REVISION, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidProtocolRevision) + ); + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + &oversized, + &valid_capabilities, + ), + Err(BrowserProtocolDescriptorError::InvalidBrowserRevision) + ); +} + +#[test] +fn capability_set_must_be_nonempty_and_canonical() { + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[], + ), + Err(BrowserProtocolDescriptorError::EmptyCapabilities) + ); + + assert_eq!( + BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[ + BrowserProtocolCapability::Navigation, + BrowserProtocolCapability::Navigation, + ], + ), + Err(BrowserProtocolDescriptorError::DuplicateCapability) + ); +} + +#[test] +fn capability_order_does_not_change_descriptor_identity() -> Result<(), Box> { + let forward = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[ + BrowserProtocolCapability::Navigation, + BrowserProtocolCapability::SemanticObservation, + BrowserProtocolCapability::TypedInput, + BrowserProtocolCapability::NetworkObservation, + ], + )?; + let reverse = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + BIDI_ADAPTER_VERSION, + BIDI_PROTOCOL_REVISION, + BROWSER_REVISION, + &[ + BrowserProtocolCapability::NetworkObservation, + BrowserProtocolCapability::TypedInput, + BrowserProtocolCapability::SemanticObservation, + BrowserProtocolCapability::Navigation, + ], + )?; + + assert_eq!(forward, reverse); + Ok(()) +} + +#[test] +fn descriptor_errors_are_stable_and_source_free() { + let cases = [ + ( + BrowserProtocolDescriptorError::InvalidAdapterVersion, + "browser protocol adapter version must be a bounded ASCII metadata token", + ), + ( + BrowserProtocolDescriptorError::InvalidProtocolRevision, + "browser protocol revision must be a bounded ASCII metadata token", + ), + ( + BrowserProtocolDescriptorError::InvalidBrowserRevision, + "browser revision must be a bounded ASCII metadata token", + ), + ( + BrowserProtocolDescriptorError::EmptyCapabilities, + "browser protocol adapter must declare at least one capability", + ), + ( + BrowserProtocolDescriptorError::DuplicateCapability, + "browser protocol adapter capabilities must be unique", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } +} + +#[test] +fn capability_requirement_errors_are_stable_and_source_free() { + let cases = [ + ( + BrowserProtocolCapability::Navigation, + "browser protocol adapter does not declare required navigation capability", + ), + ( + BrowserProtocolCapability::SemanticObservation, + "browser protocol adapter does not declare required semantic-observation capability", + ), + ( + BrowserProtocolCapability::TypedInput, + "browser protocol adapter does not declare required typed-input capability", + ), + ( + BrowserProtocolCapability::NetworkObservation, + "browser protocol adapter does not declare required network-observation capability", + ), + ]; + + for (capability, expected) in cases { + let error = BrowserProtocolCapabilityRequirementError::UnsupportedCapability(capability); + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } +} + +#[test] +fn protocol_version_requirement_error_is_stable_and_source_free() { + let error = BrowserProtocolVersionRequirementError::ProtocolVersionMismatch { + required: FUTURE_ORIGINWEAVE_PROTOCOL_VERSION, + actual: CURRENT_ORIGINWEAVE_PROTOCOL_VERSION, + }; + + assert_eq!( + error.to_string(), + "browser protocol adapter targets originweave/0.1 but originweave/0.2 is required" + ); + assert!(error.source().is_none()); +} diff --git a/crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs b/crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs new file mode 100644 index 000000000..d3ea4c7fe --- /dev/null +++ b/crates/originweave-core/tests/browser_protocol_runtime_adapter_version.rs @@ -0,0 +1,94 @@ +use std::error::Error; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolUseValidationError, OriginWeaveProtocolVersion, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn descriptor() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::Navigation], + )?) +} + +#[test] +fn runtime_adapter_version_is_bound_into_atomic_use_validation() -> Result<(), Box> { + let descriptor = descriptor()?; + + let validated = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::Navigation, + )?; + + assert_eq!(validated.adapter_version(), ADAPTER_VERSION); + Ok(()) +} + +#[test] +fn runtime_adapter_version_mismatch_precedes_revision_and_capability_checks() +-> Result<(), Box> { + let descriptor = descriptor()?; + + let error = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + "originweave-bidi-v2", + "runtime revision with spaces", + "browser/revision", + BrowserProtocolCapability::NetworkObservation, + ); + + assert_eq!( + error, + Err(BrowserProtocolUseValidationError::AdapterVersionMismatch) + ); + let error = error.err().ok_or("expected adapter version mismatch")?; + assert_eq!( + error.to_string(), + "runtime browser adapter version does not match the pinned adapter version" + ); + assert!(error.source().is_none()); + Ok(()) +} + +#[test] +fn malformed_runtime_adapter_version_fails_closed_before_revision_checks() +-> Result<(), Box> { + let descriptor = descriptor()?; + + let error = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + "runtime adapter/version", + "runtime revision with spaces", + "browser/revision", + BrowserProtocolCapability::NetworkObservation, + ); + + assert_eq!( + error, + Err(BrowserProtocolUseValidationError::InvalidAdapterVersion) + ); + let error = error.err().ok_or("expected invalid adapter version")?; + assert_eq!( + error.to_string(), + "runtime browser adapter version must be a bounded ASCII metadata token" + ); + assert!(error.source().is_none()); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs new file mode 100644 index 000000000..0ca669d7d --- /dev/null +++ b/crates/originweave-core/tests/browser_protocol_runtime_dispatch.rs @@ -0,0 +1,121 @@ +use std::{cell::Cell, error::Error}; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +type DispatchOutcome = Result<(String, BrowserProtocolCapability), &'static str>; +type DispatchFn = fn(ValidatedBrowserProtocolUse) -> DispatchOutcome; + +thread_local! { + static DISPATCH_CALLED: Cell = const { Cell::new(false) }; +} + +fn descriptor() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::Navigation], + )?) +} + +fn runtime_metadata(adapter_version: &str) -> BrowserProtocolRuntimeMetadata<'_> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + adapter_version, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn reset_dispatch_marker() { + DISPATCH_CALLED.with(|called| called.set(false)); +} + +fn dispatch_was_called() -> bool { + DISPATCH_CALLED.with(Cell::get) +} + +fn successful_dispatch(validated: ValidatedBrowserProtocolUse) -> DispatchOutcome { + DISPATCH_CALLED.with(|called| called.set(true)); + Ok(( + validated.adapter_version().to_owned(), + validated.capability(), + )) +} + +fn failing_dispatch(_: ValidatedBrowserProtocolUse) -> DispatchOutcome { + DISPATCH_CALLED.with(|called| called.set(true)); + Err("adapter-failure") +} + +#[test] +fn exact_runtime_validation_hands_single_use_proof_to_dispatch() -> Result<(), Box> { + let descriptor = descriptor()?; + reset_dispatch_marker(); + + let dispatch_result = descriptor.dispatch_if_runtime_matches( + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + )?; + + assert!(dispatch_was_called()); + assert_eq!( + dispatch_result, + Ok(( + ADAPTER_VERSION.to_owned(), + BrowserProtocolCapability::Navigation + )) + ); + Ok(()) +} + +#[test] +fn runtime_mismatch_prevents_dispatch_callback() -> Result<(), Box> { + let descriptor = descriptor()?; + reset_dispatch_marker(); + + let result = descriptor.dispatch_if_runtime_matches( + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata("originweave-bidi-v2"), + BrowserProtocolCapability::Navigation, + successful_dispatch as DispatchFn, + ); + + assert_eq!( + result, + Err(BrowserProtocolUseValidationError::AdapterVersionMismatch) + ); + assert!(!dispatch_was_called()); + Ok(()) +} + +#[test] +fn adapter_callback_failure_remains_separate_after_validation() -> Result<(), Box> { + let descriptor = descriptor()?; + reset_dispatch_marker(); + + let dispatch_result = descriptor.dispatch_if_runtime_matches( + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(ADAPTER_VERSION), + BrowserProtocolCapability::Navigation, + failing_dispatch as DispatchFn, + )?; + + assert!(dispatch_was_called()); + assert_eq!(dispatch_result, Err("adapter-failure")); + Ok(()) +} diff --git a/crates/originweave-core/tests/browser_protocol_runtime_revision.rs b/crates/originweave-core/tests/browser_protocol_runtime_revision.rs new file mode 100644 index 000000000..60a04acdb --- /dev/null +++ b/crates/originweave-core/tests/browser_protocol_runtime_revision.rs @@ -0,0 +1,96 @@ +use std::error::Error; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolRuntimeRequirementError, OriginWeaveProtocolVersion, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn descriptor() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::Navigation], + )?) +} + +#[test] +fn exact_runtime_revisions_are_required_before_adapter_use() -> Result<(), Box> { + let descriptor = descriptor()?; + assert_eq!( + descriptor.require_runtime_revisions(PROTOCOL_REVISION, BROWSER_REVISION), + Ok(()) + ); + Ok(()) +} + +#[test] +fn runtime_revision_drift_fails_closed() -> Result<(), Box> { + let descriptor = descriptor()?; + assert_eq!( + descriptor.require_runtime_revisions("webdriver-bidi-wd-2026-07-01", BROWSER_REVISION), + Err(BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch) + ); + assert_eq!( + descriptor.require_runtime_revisions(PROTOCOL_REVISION, "chromium-r1639811"), + Err(BrowserProtocolRuntimeRequirementError::BrowserRevisionMismatch) + ); + assert_eq!( + descriptor.require_runtime_revisions("webdriver-bidi-wd-2026-07-01", "chromium-r1639811"), + Err(BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch) + ); + Ok(()) +} + +#[test] +fn malformed_runtime_revision_evidence_fails_before_comparison() -> Result<(), Box> { + let descriptor = descriptor()?; + assert_eq!( + descriptor.require_runtime_revisions("webdriver bidi current", BROWSER_REVISION), + Err(BrowserProtocolRuntimeRequirementError::InvalidProtocolRevision) + ); + assert_eq!( + descriptor.require_runtime_revisions(PROTOCOL_REVISION, "chromium/current"), + Err(BrowserProtocolRuntimeRequirementError::InvalidBrowserRevision) + ); + assert_eq!( + descriptor.require_runtime_revisions("", ""), + Err(BrowserProtocolRuntimeRequirementError::InvalidProtocolRevision) + ); + Ok(()) +} + +#[test] +fn runtime_requirement_errors_are_stable_and_source_free() { + let cases = [ + ( + BrowserProtocolRuntimeRequirementError::InvalidProtocolRevision, + "runtime browser protocol revision must be a bounded ASCII metadata token", + ), + ( + BrowserProtocolRuntimeRequirementError::InvalidBrowserRevision, + "runtime browser revision must be a bounded ASCII metadata token", + ), + ( + BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch, + "runtime browser protocol revision does not match the pinned adapter revision", + ), + ( + BrowserProtocolRuntimeRequirementError::BrowserRevisionMismatch, + "runtime browser revision does not match the pinned adapter browser revision", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/browser_protocol_use_validation.rs b/crates/originweave-core/tests/browser_protocol_use_validation.rs new file mode 100644 index 000000000..31a15238c --- /dev/null +++ b/crates/originweave-core/tests/browser_protocol_use_validation.rs @@ -0,0 +1,190 @@ +use std::error::Error; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolCapabilityRequirementError, BrowserProtocolKind, + BrowserProtocolRuntimeRequirementError, BrowserProtocolUseValidationError, + BrowserProtocolVersionRequirementError, OriginWeaveProtocolVersion, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn descriptor() -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[ + BrowserProtocolCapability::Navigation, + BrowserProtocolCapability::TypedInput, + ], + )?) +} + +#[test] +fn validated_use_binds_all_required_adapter_metadata() -> Result<(), Box> { + let descriptor = descriptor()?; + let validated = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::Navigation, + )?; + + assert_eq!(validated.kind(), BrowserProtocolKind::WebDriverBiDi); + assert_eq!( + validated.originweave_protocol_version(), + ORIGINWEAVE_PROTOCOL_VERSION + ); + assert_eq!(validated.adapter_version(), ADAPTER_VERSION); + assert_eq!(validated.protocol_revision(), PROTOCOL_REVISION); + assert_eq!(validated.browser_revision(), BROWSER_REVISION); + assert_eq!( + validated.capability(), + BrowserProtocolCapability::Navigation + ); + Ok(()) +} + +#[test] +fn protocol_generation_mismatch_precedes_runtime_and_capability_checks() +-> Result<(), Box> { + let descriptor = descriptor()?; + let wrong_generation = OriginWeaveProtocolVersion::new(0, 2); + + assert_eq!( + descriptor.validate_use( + wrong_generation, + BrowserProtocolKind::ChromeDevToolsProtocol, + "runtime adapter/version", + "runtime revision with spaces", + "browser/revision", + BrowserProtocolCapability::NetworkObservation, + ), + Err(BrowserProtocolUseValidationError::ProtocolVersion( + BrowserProtocolVersionRequirementError::ProtocolVersionMismatch { + required: wrong_generation, + actual: ORIGINWEAVE_PROTOCOL_VERSION, + } + )) + ); + Ok(()) +} + +#[test] +fn runtime_protocol_kind_mismatch_precedes_adapter_revision_and_capability_checks() +-> Result<(), Box> { + let descriptor = descriptor()?; + + let error = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::ChromeDevToolsProtocol, + "runtime adapter/version", + "runtime revision with spaces", + "browser/revision", + BrowserProtocolCapability::NetworkObservation, + ); + + assert_eq!( + error, + Err(BrowserProtocolUseValidationError::ProtocolKindMismatch { + descriptor_kind: BrowserProtocolKind::WebDriverBiDi, + runtime_kind: BrowserProtocolKind::ChromeDevToolsProtocol, + }) + ); + let error = error.err().ok_or("expected protocol kind mismatch")?; + assert_eq!( + error.to_string(), + "runtime browser protocol kind does not match the pinned adapter kind" + ); + assert!(error.source().is_none()); + Ok(()) +} + +#[test] +fn runtime_revision_validation_precedes_capability_check() -> Result<(), Box> { + let descriptor = descriptor()?; + + assert_eq!( + descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + "webdriver-bidi-wd-2026-07-01", + BROWSER_REVISION, + BrowserProtocolCapability::NetworkObservation, + ), + Err(BrowserProtocolUseValidationError::RuntimeRevision( + BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch, + )) + ); + Ok(()) +} + +#[test] +fn undeclared_capability_cannot_produce_validated_use() -> Result<(), Box> { + let descriptor = descriptor()?; + + assert_eq!( + descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::NetworkObservation, + ), + Err(BrowserProtocolUseValidationError::Capability( + BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + BrowserProtocolCapability::NetworkObservation, + ), + )) + ); + Ok(()) +} + +#[test] +fn validation_errors_preserve_stable_typed_sources() { + let wrong_generation = OriginWeaveProtocolVersion::new(0, 2); + let cases = [ + ( + BrowserProtocolUseValidationError::ProtocolVersion( + BrowserProtocolVersionRequirementError::ProtocolVersionMismatch { + required: wrong_generation, + actual: ORIGINWEAVE_PROTOCOL_VERSION, + }, + ), + "browser protocol adapter targets originweave/0.1 but originweave/0.2 is required", + ), + ( + BrowserProtocolUseValidationError::RuntimeRevision( + BrowserProtocolRuntimeRequirementError::ProtocolRevisionMismatch, + ), + "runtime browser protocol revision does not match the pinned adapter revision", + ), + ( + BrowserProtocolUseValidationError::Capability( + BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + BrowserProtocolCapability::NetworkObservation, + ), + ), + "browser protocol adapter does not declare required network-observation capability", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + assert_eq!( + error.source().map(ToString::to_string).as_deref(), + Some(expected) + ); + } +} diff --git a/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs new file mode 100644 index 000000000..6223b067d --- /dev/null +++ b/crates/originweave-core/tests/browser_typed_operation_protocol_dispatch.rs @@ -0,0 +1,150 @@ +use std::{cell::Cell, error::Error, io}; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserProtocolOperation, BrowserProtocolRuntimeMetadata, DocumentEpoch, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn descriptor( + capabilities: &[BrowserProtocolCapability], +) -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capabilities, + )?) +} + +fn runtime_metadata() -> BrowserProtocolRuntimeMetadata<'static> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn origin(value: &str) -> Result> { + Origin::parse(value).map_err(|_| { + Box::new(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid controlled origin fixture", + )) as Box + }) +} + +fn typed_input_target<'a>( + registry: &mut BrowserAuthorityRegistry, + expected_origin: &'a Origin, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, expected_origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + expected_origin, + ), + epoch, + )) +} + +#[test] +fn buyer_visible_operations_map_to_exact_transport_capabilities() { + let expected = [ + ( + BrowserProtocolOperation::Navigate, + BrowserProtocolCapability::Navigation, + ), + ( + BrowserProtocolOperation::QueryNodes, + BrowserProtocolCapability::SemanticObservation, + ), + ( + BrowserProtocolOperation::ClickNode, + BrowserProtocolCapability::TypedInput, + ), + ( + BrowserProtocolOperation::TypeText, + BrowserProtocolCapability::TypedInput, + ), + ( + BrowserProtocolOperation::WaitForState, + BrowserProtocolCapability::SemanticObservation, + ), + ( + BrowserProtocolOperation::ObserveNetwork, + BrowserProtocolCapability::NetworkObservation, + ), + ]; + + for (operation, capability) in expected { + assert_eq!(operation.required_capability(), capability); + } +} + +#[test] +fn typed_operation_dispatch_derives_the_required_capability() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::TypedInput])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = origin("https://app.example")?; + let target = typed_input_target(&mut registry, &expected_origin)?; + + let result = descriptor.dispatch_operation_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolOperation::TypeText, + |validated: ValidatedBrowserProtocolUse, operation, epoch: DocumentEpoch| { + (operation, validated.capability(), epoch.value()) + }, + )?; + + assert_eq!( + result, + ( + BrowserProtocolOperation::TypeText, + BrowserProtocolCapability::TypedInput, + 1, + ) + ); + Ok(()) +} + +#[test] +fn unsupported_typed_operation_fails_before_dispatch_callback() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = origin("https://app.example")?; + let target = typed_input_target(&mut registry, &expected_origin)?; + let dispatch_called = Cell::new(false); + + let result = descriptor.dispatch_operation_if_context_origin_epoch_current( + ®istry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + BrowserProtocolOperation::ClickNode, + |_validated, _operation, _epoch| dispatch_called.set(true), + ); + + assert!(matches!( + result, + Err(BrowserContextProtocolDispatchError::ProtocolValidation(_)) + )); + assert!(!dispatch_called.get()); + Ok(()) +} diff --git a/crates/originweave-core/tests/extension_authority.rs b/crates/originweave-core/tests/extension_authority.rs index f34c30e9b..82507a244 100644 --- a/crates/originweave-core/tests/extension_authority.rs +++ b/crates/originweave-core/tests/extension_authority.rs @@ -2,7 +2,7 @@ use originweave_core::{ BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, ExtensionAccessRequest, - ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, evaluate_extension_access, + ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, evaluate_extension_access, }; fn extension_id(value: &str) -> ExtensionId { @@ -17,13 +17,6 @@ fn context(value: u64) -> BrowsingContextId { BrowsingContextId::new(value).expect("nonzero browsing context") } -fn origin(value: &str) -> Origin { - Origin::parse(value).expect("canonical origin") -} - -const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; -const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; - #[test] fn extension_id_accepts_only_canonical_chromium_extension_ids() { let canonical = "abcdefghijklmnopabcdefghijklmnop"; @@ -50,13 +43,10 @@ fn extension_id_accepts_only_canonical_chromium_extension_ids() { fn extension_agent_access_requires_an_explicit_exact_grant() { let allowed_extension = extension_id("abcdefghijklmnopabcdefghijklmnop"); let other_extension = extension_id("bcdefghijklmnopabcdefghijklmnopa"); - let granted_origin = origin("https://app.example"); let grant = ExtensionAgentGrant::new( allowed_extension.clone(), session(7), context(11), - granted_origin.clone(), - UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ObserveCurrentContext], ); @@ -64,8 +54,6 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { allowed_extension.clone(), session(7), context(11), - granted_origin.clone(), - UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -80,8 +68,6 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { other_extension, session(7), context(11), - granted_origin.clone(), - UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -93,8 +79,6 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { allowed_extension.clone(), session(8), context(11), - granted_origin.clone(), - UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -103,55 +87,24 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { ); let wrong_context = ExtensionAccessRequest::new( - allowed_extension.clone(), + allowed_extension, session(7), context(12), - granted_origin.clone(), - UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( evaluate_extension_access(&wrong_context, Some(&grant)), ExtensionAccessDecision::DenyBrowsingContextMismatch ); - - let wrong_origin = ExtensionAccessRequest::new( - allowed_extension.clone(), - session(7), - context(11), - origin("https://other.example"), - UNEXPIRED_NOW_EPOCH_SECONDS, - ExtensionAgentCapability::ObserveCurrentContext, - ); - assert_eq!( - evaluate_extension_access(&wrong_origin, Some(&grant)), - ExtensionAccessDecision::DenyOriginMismatch - ); - - let wrong_port = ExtensionAccessRequest::new( - allowed_extension, - session(7), - context(11), - origin("https://app.example:8443"), - UNEXPIRED_NOW_EPOCH_SECONDS, - ExtensionAgentCapability::ObserveCurrentContext, - ); - assert_eq!( - evaluate_extension_access(&wrong_port, Some(&grant)), - ExtensionAccessDecision::DenyOriginMismatch - ); } #[test] fn chrome_permissions_never_imply_originweave_agent_capabilities() { let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); - let granted_origin = origin("https://mail.example"); let grant = ExtensionAgentGrant::new( id.clone(), session(3), context(5), - granted_origin.clone(), - UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ObserveCurrentContext], ); @@ -159,8 +112,6 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { id, session(3), context(5), - granted_origin, - UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ProposeTypedAction, ); assert_eq!( @@ -172,13 +123,10 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { #[test] fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); - let granted_origin = origin("http://127.0.0.1:8080"); let grant = ExtensionAgentGrant::new( id.clone(), session(13), context(17), - granted_origin.clone(), - UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ ExtensionAgentCapability::ObserveCurrentContext, ExtensionAgentCapability::ProposeTypedAction, @@ -189,71 +137,10 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { ExtensionAgentCapability::ObserveCurrentContext, ExtensionAgentCapability::ProposeTypedAction, ] { - let request = ExtensionAccessRequest::new( - id.clone(), - session(13), - context(17), - granted_origin.clone(), - UNEXPIRED_NOW_EPOCH_SECONDS, - capability, - ); + let request = ExtensionAccessRequest::new(id.clone(), session(13), context(17), capability); assert_eq!( evaluate_extension_access(&request, Some(&grant)), ExtensionAccessDecision::Allow ); } } - -#[test] -fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { - let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); - let granted_origin = origin("https://billing.example"); - let expires_at_epoch_seconds = 1_700_000_100; - let grant = ExtensionAgentGrant::new( - id.clone(), - session(19), - context(23), - granted_origin.clone(), - expires_at_epoch_seconds, - [ExtensionAgentCapability::ObserveCurrentContext], - ); - - let before_deadline = ExtensionAccessRequest::new( - id.clone(), - session(19), - context(23), - granted_origin.clone(), - expires_at_epoch_seconds - 1, - ExtensionAgentCapability::ObserveCurrentContext, - ); - assert_eq!( - evaluate_extension_access(&before_deadline, Some(&grant)), - ExtensionAccessDecision::Allow - ); - - let at_deadline = ExtensionAccessRequest::new( - id.clone(), - session(19), - context(23), - granted_origin.clone(), - expires_at_epoch_seconds, - ExtensionAgentCapability::ObserveCurrentContext, - ); - assert_eq!( - evaluate_extension_access(&at_deadline, Some(&grant)), - ExtensionAccessDecision::DenyExpired - ); - - let after_deadline = ExtensionAccessRequest::new( - id, - session(19), - context(23), - granted_origin, - expires_at_epoch_seconds + 1, - ExtensionAgentCapability::ObserveCurrentContext, - ); - assert_eq!( - evaluate_extension_access(&after_deadline, Some(&grant)), - ExtensionAccessDecision::DenyExpired - ); -} diff --git a/crates/originweave-core/tests/mcp_authority_route.rs b/crates/originweave-core/tests/mcp_authority_route.rs deleted file mode 100644 index 80357ec63..000000000 --- a/crates/originweave-core/tests/mcp_authority_route.rs +++ /dev/null @@ -1,362 +0,0 @@ -use std::error::Error; - -use originweave_core::mcp::{ - MAX_MCP_METHOD_NAME_BYTES, MAX_MCP_TOOL_NAME_BYTES, MCP_PROTOCOL_VERSION, - MCP_TOOLS_CALL_METHOD, McpToolBoundaryError, ValidatedMcpToolCall, supported_mcp_tools, -}; -use originweave_core::{ActionKind, Capability, RiskClass}; - -fn validate(tool_name: &str) -> Result { - ValidatedMcpToolCall::new( - MCP_PROTOCOL_VERSION, - MCP_TOOLS_CALL_METHOD, - tool_name, - MCP_TOOLS_CALL_METHOD, - tool_name, - ) -} - -#[test] -fn supported_mcp_tools_map_to_exact_originweave_actions() -> Result<(), Box> { - let cases = [ - ( - "originweave.observe", - ActionKind::Observe, - Capability::Observe, - RiskClass::R0, - ), - ( - "originweave.extract", - ActionKind::Extract, - Capability::Extract, - RiskClass::R0, - ), - ( - "originweave.navigate", - ActionKind::Navigate, - Capability::Navigate, - RiskClass::R1, - ), - ( - "originweave.download", - ActionKind::Download, - Capability::Download, - RiskClass::R1, - ), - ( - "originweave.draft", - ActionKind::Draft, - Capability::Draft, - RiskClass::R2, - ), - ( - "originweave.submit", - ActionKind::Submit, - Capability::Submit, - RiskClass::R3, - ), - ( - "originweave.upload", - ActionKind::Upload, - Capability::Upload, - RiskClass::R3, - ), - ( - "originweave.fill_secret", - ActionKind::FillSecret, - Capability::FillSecret, - RiskClass::R3, - ), - ( - "originweave.purchase", - ActionKind::Purchase, - Capability::Purchase, - RiskClass::R4, - ), - ( - "originweave.delete", - ActionKind::Delete, - Capability::Delete, - RiskClass::R4, - ), - ( - "originweave.manage_permission", - ActionKind::ManagePermission, - Capability::ManagePermission, - RiskClass::R4, - ), - ]; - - for (tool_name, expected_action, expected_capability, expected_risk) in cases { - let call = validate(tool_name)?; - assert_eq!(call.tool_name(), tool_name); - assert_eq!(call.action_kind(), expected_action); - assert_eq!( - call.action_kind().required_capability(), - expected_capability - ); - assert_eq!(call.action_kind().risk_class(), expected_risk); - } - Ok(()) -} - -#[test] -fn mcp_tool_catalog_is_deterministic_complete_and_action_unambiguous() -> Result<(), Box> -{ - let expected = [ - ("originweave.observe", ActionKind::Observe), - ("originweave.extract", ActionKind::Extract), - ("originweave.navigate", ActionKind::Navigate), - ("originweave.download", ActionKind::Download), - ("originweave.draft", ActionKind::Draft), - ("originweave.submit", ActionKind::Submit), - ("originweave.upload", ActionKind::Upload), - ("originweave.fill_secret", ActionKind::FillSecret), - ("originweave.purchase", ActionKind::Purchase), - ("originweave.delete", ActionKind::Delete), - ( - "originweave.manage_permission", - ActionKind::ManagePermission, - ), - ]; - let catalog = supported_mcp_tools(); - - assert_eq!(catalog.len(), expected.len()); - for (entry, (expected_name, expected_action)) in catalog.iter().zip(expected) { - assert_eq!(entry.tool_name(), expected_name); - assert_eq!(entry.action_kind(), expected_action); - assert_eq!( - entry.required_capability(), - expected_action.required_capability() - ); - assert_eq!(entry.risk_class(), expected_action.risk_class()); - - let call = validate(entry.tool_name())?; - assert_eq!(call.action_kind(), entry.action_kind()); - } - - for (index, entry) in catalog.iter().enumerate() { - for other in &catalog[index + 1..] { - assert_ne!(entry.tool_name(), other.tool_name()); - assert_ne!(entry.action_kind(), other.action_kind()); - } - } - assert!( - catalog - .iter() - .all(|entry| entry.action_kind() != ActionKind::LegalConsent) - ); - Ok(()) -} - -#[test] -fn mcp_route_rejects_protocol_header_body_and_method_drift() { - assert_eq!( - ValidatedMcpToolCall::new( - "2025-11-25", - MCP_TOOLS_CALL_METHOD, - "originweave.observe", - MCP_TOOLS_CALL_METHOD, - "originweave.observe", - ), - Err(McpToolBoundaryError::UnsupportedProtocolVersion) - ); - assert_eq!( - ValidatedMcpToolCall::new( - MCP_PROTOCOL_VERSION, - MCP_TOOLS_CALL_METHOD, - "originweave.observe", - "tools/list", - "originweave.observe", - ), - Err(McpToolBoundaryError::HeaderBodyMismatch) - ); - assert_eq!( - ValidatedMcpToolCall::new( - MCP_PROTOCOL_VERSION, - MCP_TOOLS_CALL_METHOD, - "originweave.observe", - MCP_TOOLS_CALL_METHOD, - "originweave.extract", - ), - Err(McpToolBoundaryError::HeaderBodyMismatch) - ); - assert_eq!( - ValidatedMcpToolCall::new( - MCP_PROTOCOL_VERSION, - "resources/read", - "originweave.observe", - "resources/read", - "originweave.observe", - ), - Err(McpToolBoundaryError::UnsupportedMethod) - ); -} - -#[test] -fn mcp_route_bounds_each_untrusted_method_before_cross_field_comparison() { - let at_limit = "x".repeat(MAX_MCP_METHOD_NAME_BYTES); - let oversized_routing = "r".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); - let oversized_body = "b".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); - - assert_eq!( - ValidatedMcpToolCall::new( - MCP_PROTOCOL_VERSION, - "", - "originweave.observe", - MCP_TOOLS_CALL_METHOD, - "originweave.observe", - ), - Err(McpToolBoundaryError::InvalidMethod) - ); - assert_eq!( - ValidatedMcpToolCall::new( - MCP_PROTOCOL_VERSION, - MCP_TOOLS_CALL_METHOD, - "originweave.observe", - "", - "originweave.observe", - ), - Err(McpToolBoundaryError::InvalidMethod) - ); - assert_eq!( - ValidatedMcpToolCall::new( - MCP_PROTOCOL_VERSION, - &oversized_routing, - "originweave.observe", - MCP_TOOLS_CALL_METHOD, - "originweave.observe", - ), - Err(McpToolBoundaryError::InvalidMethod) - ); - assert_eq!( - ValidatedMcpToolCall::new( - MCP_PROTOCOL_VERSION, - MCP_TOOLS_CALL_METHOD, - "originweave.observe", - &oversized_body, - "originweave.observe", - ), - Err(McpToolBoundaryError::InvalidMethod) - ); - assert_eq!( - ValidatedMcpToolCall::new( - MCP_PROTOCOL_VERSION, - "tools call", - "originweave.observe", - "tools call", - "originweave.observe", - ), - Err(McpToolBoundaryError::InvalidMethod) - ); - assert_eq!( - ValidatedMcpToolCall::new( - MCP_PROTOCOL_VERSION, - &at_limit, - "originweave.observe", - &at_limit, - "originweave.observe", - ), - Err(McpToolBoundaryError::UnsupportedMethod) - ); -} - -#[test] -fn mcp_route_rejects_unbounded_malformed_and_unmapped_tool_names() { - let at_limit = "x".repeat(MAX_MCP_TOOL_NAME_BYTES); - let oversized = "x".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); - for tool_name in [ - "", - "originweave legal", - "originweave/observe", - "originweave.관찰", - &oversized, - ] { - assert_eq!( - validate(tool_name), - Err(McpToolBoundaryError::InvalidToolName) - ); - } - - assert_eq!(validate(&at_limit), Err(McpToolBoundaryError::UnknownTool)); - assert_eq!( - validate("originweave.legal_consent"), - Err(McpToolBoundaryError::UnknownTool) - ); - assert_eq!( - validate("third_party.arbitrary_javascript"), - Err(McpToolBoundaryError::UnknownTool) - ); -} - -#[test] -fn mcp_route_validates_each_untrusted_tool_name_before_cross_field_comparison() { - let oversized_routing = "r".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); - let oversized_body = "b".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); - - assert_eq!( - ValidatedMcpToolCall::new( - MCP_PROTOCOL_VERSION, - MCP_TOOLS_CALL_METHOD, - &oversized_routing, - MCP_TOOLS_CALL_METHOD, - "originweave.observe", - ), - Err(McpToolBoundaryError::InvalidToolName) - ); - assert_eq!( - ValidatedMcpToolCall::new( - MCP_PROTOCOL_VERSION, - MCP_TOOLS_CALL_METHOD, - "originweave.observe", - MCP_TOOLS_CALL_METHOD, - &oversized_body, - ), - Err(McpToolBoundaryError::InvalidToolName) - ); - assert_eq!( - ValidatedMcpToolCall::new( - MCP_PROTOCOL_VERSION, - MCP_TOOLS_CALL_METHOD, - "originweave/observe", - MCP_TOOLS_CALL_METHOD, - "originweave.observe", - ), - Err(McpToolBoundaryError::InvalidToolName) - ); -} - -#[test] -fn mcp_boundary_errors_are_deterministic_and_do_not_echo_untrusted_values() { - let cases = [ - ( - McpToolBoundaryError::UnsupportedProtocolVersion, - "unsupported MCP protocol version", - ), - ( - McpToolBoundaryError::HeaderBodyMismatch, - "MCP routing headers do not match the request body", - ), - ( - McpToolBoundaryError::UnsupportedMethod, - "only MCP tools/call requests can enter the typed action boundary", - ), - ( - McpToolBoundaryError::InvalidMethod, - "MCP method violates the bounded ASCII routing syntax", - ), - ( - McpToolBoundaryError::InvalidToolName, - "MCP tool name violates the bounded ASCII routing syntax", - ), - ( - McpToolBoundaryError::UnknownTool, - "MCP tool is not mapped to an OriginWeave typed action", - ), - ]; - - for (error, expected_message) in cases { - assert_eq!(error.to_string(), expected_message); - assert!(error.source().is_none()); - } -} diff --git a/crates/originweave-core/tests/mcp_tools_list_cache.rs b/crates/originweave-core/tests/mcp_tools_list_cache.rs deleted file mode 100644 index 9d3681673..000000000 --- a/crates/originweave-core/tests/mcp_tools_list_cache.rs +++ /dev/null @@ -1,221 +0,0 @@ -use std::error::Error; - -use originweave_core::mcp::{ - MAX_MCP_METHOD_NAME_BYTES, MCP_PROTOCOL_VERSION, MCP_TOOLS_LIST_METHOD, McpCacheScope, - McpResultType, McpToolsListBoundaryError, ValidatedMcpToolsListRequest, mcp_tools_list_page, - supported_mcp_tools, -}; - -#[test] -fn mcp_tools_list_page_is_complete_private_and_immediately_stale() { - let page = mcp_tools_list_page(); - - assert_eq!(page.result_type(), McpResultType::Complete); - assert_eq!(page.tools(), supported_mcp_tools()); - assert_eq!(page.ttl_ms(), 0); - assert_eq!(page.cache_scope(), McpCacheScope::Private); - assert_eq!(page.next_cursor(), None); -} - -fn valid_tools_list_request( - cursor: Option<&str>, -) -> Result { - ValidatedMcpToolsListRequest::new( - Some(MCP_PROTOCOL_VERSION), - Some(MCP_PROTOCOL_VERSION), - true, - MCP_TOOLS_LIST_METHOD, - MCP_TOOLS_LIST_METHOD, - cursor, - ) -} - -#[test] -fn mcp_tools_list_request_requires_complete_request_metadata() { - assert_eq!( - valid_tools_list_request(None).map(|validated| validated.method()), - Ok(MCP_TOOLS_LIST_METHOD) - ); - - assert_eq!( - ValidatedMcpToolsListRequest::new( - None, - Some(MCP_PROTOCOL_VERSION), - true, - MCP_TOOLS_LIST_METHOD, - MCP_TOOLS_LIST_METHOD, - None, - ), - Err(McpToolsListBoundaryError::MissingProtocolVersionHeader) - ); - assert_eq!( - ValidatedMcpToolsListRequest::new( - Some(MCP_PROTOCOL_VERSION), - None, - true, - MCP_TOOLS_LIST_METHOD, - MCP_TOOLS_LIST_METHOD, - None, - ), - Err(McpToolsListBoundaryError::MissingProtocolVersionMetadata) - ); - assert_eq!( - ValidatedMcpToolsListRequest::new( - Some(MCP_PROTOCOL_VERSION), - Some("2025-11-25"), - true, - MCP_TOOLS_LIST_METHOD, - MCP_TOOLS_LIST_METHOD, - None, - ), - Err(McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch) - ); - assert_eq!( - ValidatedMcpToolsListRequest::new( - Some("2025-11-25"), - Some("2025-11-25"), - true, - MCP_TOOLS_LIST_METHOD, - MCP_TOOLS_LIST_METHOD, - None, - ), - Err(McpToolsListBoundaryError::UnsupportedProtocolVersion) - ); - assert_eq!( - ValidatedMcpToolsListRequest::new( - Some(MCP_PROTOCOL_VERSION), - Some(MCP_PROTOCOL_VERSION), - false, - MCP_TOOLS_LIST_METHOD, - MCP_TOOLS_LIST_METHOD, - None, - ), - Err(McpToolsListBoundaryError::MissingClientCapabilities) - ); -} - -#[test] -fn mcp_tools_list_bounds_protocol_metadata_before_cross_field_comparison() { - let oversized_protocol_version = format!("{MCP_PROTOCOL_VERSION}0"); - - for (header, metadata) in [ - (oversized_protocol_version.as_str(), MCP_PROTOCOL_VERSION), - (MCP_PROTOCOL_VERSION, oversized_protocol_version.as_str()), - ] { - assert_eq!( - ValidatedMcpToolsListRequest::new( - Some(header), - Some(metadata), - true, - MCP_TOOLS_LIST_METHOD, - MCP_TOOLS_LIST_METHOD, - None, - ), - Err(McpToolsListBoundaryError::UnsupportedProtocolVersion) - ); - } -} - -#[test] -fn mcp_tools_list_validates_each_method_before_cross_field_comparison() { - let oversized_method = "a".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); - - for (routing_method, body_method) in [ - ("tools list", MCP_TOOLS_LIST_METHOD), - (MCP_TOOLS_LIST_METHOD, "tools list"), - (oversized_method.as_str(), MCP_TOOLS_LIST_METHOD), - (MCP_TOOLS_LIST_METHOD, oversized_method.as_str()), - ] { - assert_eq!( - ValidatedMcpToolsListRequest::new( - Some(MCP_PROTOCOL_VERSION), - Some(MCP_PROTOCOL_VERSION), - true, - routing_method, - body_method, - None, - ), - Err(McpToolsListBoundaryError::InvalidMethod) - ); - } -} - -#[test] -fn mcp_tools_list_request_requires_exact_routing_and_no_unissued_cursor() { - assert_eq!( - ValidatedMcpToolsListRequest::new( - Some(MCP_PROTOCOL_VERSION), - Some(MCP_PROTOCOL_VERSION), - true, - MCP_TOOLS_LIST_METHOD, - "tools/call", - None, - ), - Err(McpToolsListBoundaryError::MethodHeaderBodyMismatch) - ); - assert_eq!( - ValidatedMcpToolsListRequest::new( - Some(MCP_PROTOCOL_VERSION), - Some(MCP_PROTOCOL_VERSION), - true, - "resources/list", - "resources/list", - None, - ), - Err(McpToolsListBoundaryError::UnsupportedMethod) - ); - - for cursor in ["cursor-1", ""] { - assert_eq!( - valid_tools_list_request(Some(cursor)), - Err(McpToolsListBoundaryError::UnsupportedCursor) - ); - } -} - -#[test] -fn mcp_tools_list_request_errors_are_source_free_and_non_echoing() { - let cases = [ - ( - McpToolsListBoundaryError::MissingProtocolVersionHeader, - "MCP protocol version header is required", - ), - ( - McpToolsListBoundaryError::MissingProtocolVersionMetadata, - "MCP request metadata protocol version is required", - ), - ( - McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch, - "MCP protocol version header does not match request metadata", - ), - ( - McpToolsListBoundaryError::UnsupportedProtocolVersion, - "unsupported MCP protocol version", - ), - ( - McpToolsListBoundaryError::MissingClientCapabilities, - "MCP request metadata client capabilities are required", - ), - ( - McpToolsListBoundaryError::InvalidMethod, - "MCP method violates the bounded ASCII routing syntax", - ), - ( - McpToolsListBoundaryError::MethodHeaderBodyMismatch, - "MCP method header does not match the request body", - ), - ( - McpToolsListBoundaryError::UnsupportedMethod, - "only MCP tools/list requests can enter the discovery boundary", - ), - ( - McpToolsListBoundaryError::UnsupportedCursor, - "MCP tools/list cursor was not issued by this fixed catalog", - ), - ]; - - for (error, expected) in cases { - assert_eq!(error.to_string(), expected); - assert!(error.source().is_none()); - } -} diff --git a/crates/originweave-core/tests/origin_port_syntax.rs b/crates/originweave-core/tests/origin_port_syntax.rs deleted file mode 100644 index ce58e523e..000000000 --- a/crates/originweave-core/tests/origin_port_syntax.rs +++ /dev/null @@ -1,18 +0,0 @@ -use originweave_core::{Origin, OriginError}; - -#[test] -fn origin_rejects_non_digit_port_prefixes() { - for input in [ - "https://example.com:+443", - "https://example.com:+8443", - "http://localhost:+80", - "http://127.0.0.1:+8080", - "https://[2001:db8::1]:+443", - ] { - assert_eq!( - Origin::parse(input), - Err(OriginError::InvalidPort), - "input={input}" - ); - } -} diff --git a/crates/originweave-core/tests/protocol_version_parsing.rs b/crates/originweave-core/tests/protocol_version_parsing.rs new file mode 100644 index 000000000..189bde6be --- /dev/null +++ b/crates/originweave-core/tests/protocol_version_parsing.rs @@ -0,0 +1,59 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; +use std::str::FromStr; + +use originweave_core::{OriginWeaveProtocolVersion, OriginWeaveProtocolVersionParseError}; + +#[test] +fn canonical_protocol_versions_parse_and_round_trip() -> Result<(), Box> { + let current = OriginWeaveProtocolVersion::from_str("originweave/0.1")?; + assert_eq!(current, OriginWeaveProtocolVersion::new(0, 1)); + assert_eq!(current.to_string(), "originweave/0.1"); + + let maximum = OriginWeaveProtocolVersion::from_str("originweave/65535.65535")?; + assert_eq!(maximum, OriginWeaveProtocolVersion::new(u16::MAX, u16::MAX)); + assert_eq!(maximum.to_string(), "originweave/65535.65535"); + Ok(()) +} + +#[test] +fn malformed_or_noncanonical_protocol_versions_fail_closed() { + let malformed = [ + "", + "originweave/", + "originweave/0", + "originweave/0.", + "originweave/.1", + "originweave/0.1.0", + "OriginWeave/0.1", + "originweave/00.1", + "originweave/0.01", + "originweave/+0.1", + "originweave/0.+1", + "originweave/-0.1", + "originweave/0.-1", + "originweave/65536.1", + "originweave/0.65536", + " originweave/0.1", + "originweave/0.1 ", + "originweave/0.1", + ]; + + for value in malformed { + assert_eq!( + OriginWeaveProtocolVersion::from_str(value), + Err(OriginWeaveProtocolVersionParseError::InvalidFormat) + ); + } +} + +#[test] +fn protocol_version_parse_error_is_stable_and_source_free() { + let error = OriginWeaveProtocolVersionParseError::InvalidFormat; + assert_eq!( + error.to_string(), + "OriginWeave protocol version must use canonical originweave/. syntax" + ); + assert!(error.source().is_none()); +} diff --git a/crates/originweave-core/tests/protocol_version_runtime_coverage.rs b/crates/originweave-core/tests/protocol_version_runtime_coverage.rs new file mode 100644 index 000000000..aeca15dec --- /dev/null +++ b/crates/originweave-core/tests/protocol_version_runtime_coverage.rs @@ -0,0 +1,12 @@ +use originweave_core::OriginWeaveProtocolVersion; + +#[test] +fn protocol_version_can_be_constructed_from_runtime_values() { + let major = std::hint::black_box(0_u16); + let minor = std::hint::black_box(1_u16); + let version = OriginWeaveProtocolVersion::new(major, minor); + + assert_eq!(version.major(), 0); + assert_eq!(version.minor(), 1); + assert_eq!(version.to_string(), "originweave/0.1"); +} diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs deleted file mode 100644 index 3e37fab18..000000000 --- a/crates/originweave-core/tests/release_acceptance.rs +++ /dev/null @@ -1,397 +0,0 @@ -use originweave_core::release_acceptance::{ - BenchmarkSuite, BenchmarkSuiteOutcome, DeclaredLimitation, MAX_DECLARED_RELEASE_LIMITATIONS, - ReleaseDecision, ReleaseDecisionError, decide_release, -}; - -fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { - BenchmarkSuite::ALL - .into_iter() - .map(|suite| (suite, BenchmarkSuiteOutcome::Passed)) - .collect() -} - -fn declared_limitation() -> Result { - DeclaredLimitation::new( - "linux_arm64", - "Linux ARM64 is not included in the declared release support profile.", - ) -} - -#[test] -fn generic_constructor_input_shapes_cover_success_paths_in_this_test_crate() { - assert!( - DeclaredLimitation::new(String::from("linux_arm64"), "Linux ARM64 is unsupported.").is_ok() - ); - assert!( - DeclaredLimitation::new("linux_arm64", String::from("Linux ARM64 is unsupported.")).is_ok() - ); -} - -#[test] -fn complete_passing_evidence_is_accepted_without_declared_limitations() --> Result<(), ReleaseDecisionError> { - let report = decide_release(passing_results(), &[])?; - - assert_eq!(report.decision(), ReleaseDecision::Accepted); - assert!(report.failed_suites().is_empty()); - assert!(report.inconclusive_suites().is_empty()); - assert!(report.missing_suites().is_empty()); - assert!(report.declared_limitations().is_empty()); - Ok(()) -} - -#[test] -fn complete_passing_evidence_preserves_declared_limitation_details() --> Result<(), ReleaseDecisionError> { - let limitation = declared_limitation()?; - let report = decide_release(passing_results(), std::slice::from_ref(&limitation))?; - - assert_eq!( - report.decision(), - ReleaseDecision::AcceptedWithDeclaredLimitations - ); - assert_eq!(report.declared_limitations(), &[limitation]); - Ok(()) -} - -#[test] -fn limitation_requires_an_unsupported_claim() { - assert_eq!( - DeclaredLimitation::new( - " ", - "A buyer-visible consequence must not stand without the narrowed claim.", - ), - Err(ReleaseDecisionError::EmptyLimitationClaim) - ); -} - -#[test] -fn limitation_requires_a_buyer_visible_consequence() { - assert_eq!( - DeclaredLimitation::new("linux_arm64", "\t\n"), - Err(ReleaseDecisionError::EmptyLimitationConsequence) - ); -} - -#[test] -fn limitation_rejects_control_characters_in_release_metadata() { - assert_eq!( - DeclaredLimitation::new( - "linux_arm64\nforged_release_claim", - "Linux ARM64 is unsupported." - ), - Err(ReleaseDecisionError::InvalidLimitationClaim) - ); - assert_eq!( - DeclaredLimitation::new( - "linux_arm64", - "Linux ARM64 is unsupported.\rforged_release_consequence" - ), - Err(ReleaseDecisionError::InvalidLimitationConsequence) - ); -} - -#[test] -fn limitation_rejects_ambiguous_unicode_formatting_characters() { - for character in [ - '\u{00ad}', '\u{061c}', '\u{180e}', '\u{200b}', '\u{200f}', '\u{2028}', '\u{202e}', - '\u{2060}', '\u{2066}', '\u{206f}', '\u{feff}', - ] { - assert_eq!( - DeclaredLimitation::new( - format!("linux_arm64{character}forged_release_claim"), - "Linux ARM64 is unsupported." - ), - Err(ReleaseDecisionError::InvalidLimitationClaim) - ); - assert_eq!( - DeclaredLimitation::new( - "linux_arm64", - format!("Linux ARM64 is unsupported.{character}forged_release_consequence") - ), - Err(ReleaseDecisionError::InvalidLimitationConsequence) - ); - } -} - -#[test] -fn limitation_preserves_unambiguous_international_buyer_text() -> Result<(), ReleaseDecisionError> { - let limitation = DeclaredLimitation::new( - "한국어_운영환경", - "이 운영환경은 현재 지원 범위에 포함되지 않습니다.", - )?; - - assert_eq!(limitation.unsupported_claim(), "한국어_운영환경"); - assert_eq!( - limitation.buyer_consequence(), - "이 운영환경은 현재 지원 범위에 포함되지 않습니다." - ); - Ok(()) -} - -#[test] -fn limitation_errors_have_deterministic_standard_error_contracts() { - let cases = [ - ( - ReleaseDecisionError::EmptyLimitationClaim, - "declared release limitation must name an unsupported claim", - ), - ( - ReleaseDecisionError::InvalidLimitationClaim, - "declared release limitation claim is not canonical or contains an unsafe presentation character", - ), - ( - ReleaseDecisionError::EmptyLimitationConsequence, - "declared release limitation must state a buyer-visible consequence", - ), - ( - ReleaseDecisionError::InvalidLimitationConsequence, - "declared release limitation consequence is not canonical or contains an unsafe presentation character", - ), - ( - ReleaseDecisionError::DuplicateLimitationClaim, - "benchmark release decision contains duplicate limitation claim", - ), - ]; - - for (error, expected_message) in cases { - assert_eq!(error.to_string(), expected_message); - let standard_error: &dyn std::error::Error = &error; - assert!(standard_error.source().is_none()); - } -} - -#[test] -fn limitation_exposes_the_exact_narrowed_claim_and_consequence() -> Result<(), ReleaseDecisionError> -{ - let limitation = declared_limitation()?; - - assert_eq!(limitation.unsupported_claim(), "linux_arm64"); - assert_eq!( - limitation.buyer_consequence(), - "Linux ARM64 is not included in the declared release support profile." - ); - Ok(()) -} - -#[test] -fn every_mandatory_suite_is_required_for_acceptance() -> Result<(), ReleaseDecisionError> { - for omitted_suite in BenchmarkSuite::ALL { - let evidence = passing_results() - .into_iter() - .filter(|(suite, _)| *suite != omitted_suite) - .collect::>(); - - let report = decide_release(evidence, &[])?; - - assert_eq!(report.decision(), ReleaseDecision::Inconclusive); - assert_eq!(report.missing_suites(), &[omitted_suite]); - assert!(report.failed_suites().is_empty()); - } - Ok(()) -} - -#[test] -fn explicit_inconclusive_suite_evidence_cannot_be_promoted_to_acceptance() --> Result<(), ReleaseDecisionError> { - for inconclusive_suite in BenchmarkSuite::ALL { - let evidence = passing_results() - .into_iter() - .map(|(suite, outcome)| { - if suite == inconclusive_suite { - (suite, BenchmarkSuiteOutcome::Inconclusive) - } else { - (suite, outcome) - } - }) - .collect::>(); - let limitation = declared_limitation()?; - - let report = decide_release(evidence, std::slice::from_ref(&limitation))?; - - assert_eq!(report.decision(), ReleaseDecision::Inconclusive); - assert_eq!(report.inconclusive_suites(), &[inconclusive_suite]); - assert_eq!(report.declared_limitations(), &[limitation]); - } - Ok(()) -} - -#[test] -fn any_known_threshold_failure_rejects_release_and_identifies_the_suite() --> Result<(), ReleaseDecisionError> { - for failed_suite in BenchmarkSuite::ALL { - let evidence = passing_results() - .into_iter() - .map(|(suite, outcome)| { - if suite == failed_suite { - (suite, BenchmarkSuiteOutcome::Failed) - } else { - (suite, outcome) - } - }) - .collect::>(); - let limitation = declared_limitation()?; - - let report = decide_release(evidence, std::slice::from_ref(&limitation))?; - - assert_eq!(report.decision(), ReleaseDecision::Rejected); - assert_eq!(report.failed_suites(), &[failed_suite]); - assert_eq!(report.declared_limitations(), &[limitation]); - } - Ok(()) -} - -#[test] -fn known_failure_remains_rejected_when_other_evidence_is_incomplete() --> Result<(), ReleaseDecisionError> { - let report = decide_release( - vec![ - ( - BenchmarkSuite::ControlledDeterministic, - BenchmarkSuiteOutcome::Failed, - ), - ( - BenchmarkSuite::WebCompatibility, - BenchmarkSuiteOutcome::Inconclusive, - ), - ], - &[], - )?; - - assert_eq!(report.decision(), ReleaseDecision::Rejected); - assert_eq!( - report.failed_suites(), - &[BenchmarkSuite::ControlledDeterministic] - ); - assert_eq!( - report.inconclusive_suites(), - &[BenchmarkSuite::WebCompatibility] - ); - assert_eq!( - report.missing_suites(), - &[ - BenchmarkSuite::SecurityAdversarial, - BenchmarkSuite::ReliabilityRecovery, - BenchmarkSuite::EnterpriseOperability, - ] - ); - Ok(()) -} - -#[test] -fn duplicate_suite_evidence_fails_closed_instead_of_overwriting_results() { - for duplicate_suite in BenchmarkSuite::ALL { - let expected_error = ReleaseDecisionError::DuplicateSuite(duplicate_suite); - assert_eq!( - decide_release( - vec![ - (duplicate_suite, BenchmarkSuiteOutcome::Passed), - (duplicate_suite, BenchmarkSuiteOutcome::Failed), - ], - &[], - ), - Err(expected_error) - ); - - assert_eq!( - expected_error.to_string(), - format!( - "benchmark release evidence contains duplicate suite: {}", - duplicate_suite.as_str() - ) - ); - let standard_error: &dyn std::error::Error = &expected_error; - assert!(standard_error.source().is_none()); - } -} - -#[test] -fn duplicate_suite_evidence_in_vector_input_also_fails_closed() { - let duplicate_suite = BenchmarkSuite::ControlledDeterministic; - let mut evidence = passing_results(); - evidence.push((duplicate_suite, BenchmarkSuiteOutcome::Failed)); - - assert_eq!( - decide_release(evidence, &[]), - Err(ReleaseDecisionError::DuplicateSuite(duplicate_suite)) - ); -} - -#[test] -fn decision_is_independent_of_evidence_input_order() { - let mut reversed = passing_results(); - reversed.reverse(); - - assert_eq!( - decide_release(reversed, &[]), - decide_release(passing_results(), &[]) - ); -} - -#[test] -fn conflicting_consequences_for_one_limitation_claim_fail_closed() --> Result<(), ReleaseDecisionError> { - let first = DeclaredLimitation::new( - "linux_arm64", - "Linux ARM64 is excluded from the support profile.", - )?; - let conflicting = DeclaredLimitation::new( - "linux_arm64", - "Linux ARM64 is supported only for evaluation deployments.", - )?; - - assert_eq!( - decide_release(passing_results(), &[first, conflicting]), - Err(ReleaseDecisionError::DuplicateLimitationClaim) - ); - Ok(()) -} - -#[test] -fn duplicate_limitation_claim_fails_closed_even_when_consequence_matches() --> Result<(), ReleaseDecisionError> { - let limitation = declared_limitation()?; - - assert_eq!( - decide_release(passing_results(), &[limitation.clone(), limitation],), - Err(ReleaseDecisionError::DuplicateLimitationClaim) - ); - Ok(()) -} - -#[test] -fn release_report_bounds_declared_limitation_count_before_cloning() --> Result<(), ReleaseDecisionError> { - let maximum = (0..MAX_DECLARED_RELEASE_LIMITATIONS) - .map(|index| { - DeclaredLimitation::new( - format!("unsupported_profile_{index}"), - "This profile is excluded from the declared support profile.", - ) - }) - .collect::, _>>()?; - let report = decide_release(passing_results(), &maximum)?; - - assert_eq!( - report.decision(), - ReleaseDecision::AcceptedWithDeclaredLimitations - ); - assert_eq!( - report.declared_limitations().len(), - MAX_DECLARED_RELEASE_LIMITATIONS - ); - - let too_many = (0..=MAX_DECLARED_RELEASE_LIMITATIONS) - .map(|index| { - DeclaredLimitation::new( - format!("unsupported_profile_{index}"), - "This profile is excluded from the declared support profile.", - ) - }) - .collect::, _>>()?; - assert_eq!( - decide_release(passing_results(), &too_many), - Err(ReleaseDecisionError::TooManyDeclaredLimitations) - ); - Ok(()) -} diff --git a/crates/originweave-core/tests/release_acceptance_canonical_text.rs b/crates/originweave-core/tests/release_acceptance_canonical_text.rs deleted file mode 100644 index 2d7840af3..000000000 --- a/crates/originweave-core/tests/release_acceptance_canonical_text.rs +++ /dev/null @@ -1,116 +0,0 @@ -use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; - -#[test] -fn limitation_accepts_canonical_boundary_text() { - let limitation = DeclaredLimitation::new( - "linux_arm64", - "Linux ARM64 is excluded from the support profile.", - ); - - assert_eq!( - limitation - .as_ref() - .map(|value| (value.unsupported_claim(), value.buyer_consequence())), - Ok(( - "linux_arm64", - "Linux ARM64 is excluded from the support profile." - )) - ); -} - -#[test] -fn limitation_rejects_empty_fields_for_the_canonical_string_input_shape() { - assert_eq!( - DeclaredLimitation::new("", "Linux ARM64 is excluded from the support profile."), - Err(ReleaseDecisionError::EmptyLimitationClaim), - ); - assert_eq!( - DeclaredLimitation::new("linux_arm64", ""), - Err(ReleaseDecisionError::EmptyLimitationConsequence), - ); -} - -#[test] -fn limitation_rejects_surrounding_whitespace_that_changes_claim_identity() { - for unsupported_claim in [" linux_arm64", "linux_arm64 ", "\tlinux_arm64"] { - assert_eq!( - DeclaredLimitation::new( - unsupported_claim, - "Linux ARM64 is excluded from the support profile.", - ), - Err(ReleaseDecisionError::InvalidLimitationClaim), - "surrounding whitespace must not create a second spelling for one claim identity: {unsupported_claim:?}", - ); - } -} - -#[test] -fn limitation_rejects_surrounding_whitespace_in_buyer_consequence() { - for buyer_consequence in [ - " Linux ARM64 is excluded from the support profile.", - "Linux ARM64 is excluded from the support profile. ", - "Linux ARM64 is excluded from the support profile.\t", - ] { - assert_eq!( - DeclaredLimitation::new("linux_arm64", buyer_consequence), - Err(ReleaseDecisionError::InvalidLimitationConsequence), - "buyer-visible consequence must have one canonical boundary spelling: {buyer_consequence:?}", - ); - } -} - -#[test] -fn limitation_rejects_non_nfc_claim_identity() { - let nfc_claim = "caf\u{e9}"; - let canonically_equivalent_nfd_claim = "cafe\u{301}"; - - assert!( - DeclaredLimitation::new( - nfc_claim, - "This normalized claim remains a supported buyer-visible spelling.", - ) - .is_ok(), - "NFC international text must remain admissible", - ); - assert_eq!( - DeclaredLimitation::new( - canonically_equivalent_nfd_claim, - "This decomposed spelling must not create a second claim identity.", - ), - Err(ReleaseDecisionError::InvalidLimitationClaim), - "canonically equivalent NFD text must not bypass limitation identity", - ); -} - -#[test] -fn limitation_rejects_non_nfc_buyer_consequence() { - assert_eq!( - DeclaredLimitation::new( - "linux_arm64", - "Cafe\u{301} support is excluded from this profile.", - ), - Err(ReleaseDecisionError::InvalidLimitationConsequence), - "buyer-visible consequences must use one canonical Unicode spelling", - ); -} - -#[test] -fn invalid_canonical_text_errors_describe_all_rejected_causes() { - let claim_result = DeclaredLimitation::new( - " linux_arm64", - "Linux ARM64 is excluded from the support profile.", - ); - assert_eq!( - claim_result.as_ref().map_err(ToString::to_string), - Err("declared release limitation claim is not canonical or contains an unsafe presentation character".to_owned()) - ); - - let consequence_result = DeclaredLimitation::new( - "linux_arm64", - "Cafe\u{301} support is excluded from this profile.", - ); - assert_eq!( - consequence_result.as_ref().map_err(ToString::to_string), - Err("declared release limitation consequence is not canonical or contains an unsafe presentation character".to_owned()) - ); -} diff --git a/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs b/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs deleted file mode 100644 index 0dfb20ba3..000000000 --- a/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs +++ /dev/null @@ -1,46 +0,0 @@ -use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; - -#[test] -fn punctuation_only_limitation_claim_does_not_name_an_unsupported_claim() { - assert_eq!( - DeclaredLimitation::new("---", "Linux ARM64 is excluded from the support profile."), - Err(ReleaseDecisionError::InvalidLimitationClaim) - ); -} - -#[test] -fn punctuation_only_limitation_consequence_does_not_state_a_buyer_consequence() { - assert_eq!( - DeclaredLimitation::new("linux_arm64", "..."), - Err(ReleaseDecisionError::InvalidLimitationConsequence) - ); -} - -#[test] -fn meaningful_text_may_begin_with_allowed_punctuation() { - assert!( - DeclaredLimitation::new( - "-linux_arm64", - "Linux ARM64 is excluded from the support profile.", - ) - .is_ok() - ); - assert!( - DeclaredLimitation::new( - "linux_arm64", - "... Linux ARM64 remains outside the support profile.", - ) - .is_ok() - ); -} - -#[test] -fn international_alphanumeric_limitation_text_remains_admissible() { - assert!( - DeclaredLimitation::new( - "한국어_운영환경", - "이 운영환경은 현재 지원 범위에 포함되지 않습니다.", - ) - .is_ok() - ); -} diff --git a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs deleted file mode 100644 index fd45e0e6d..000000000 --- a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs +++ /dev/null @@ -1,98 +0,0 @@ -use originweave_core::release_acceptance::{ - DeclaredLimitation, MAX_RELEASE_LIMITATION_TEXT_BYTES, ReleaseDecisionError, -}; - -#[test] -fn limitation_metadata_enforces_exact_utf8_byte_budget() -> Result<(), ReleaseDecisionError> { - let maximum_claim = "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); - let maximum_consequence = "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); - let limitation = DeclaredLimitation::new(maximum_claim.as_str(), maximum_consequence.as_str())?; - - assert_eq!(limitation.unsupported_claim(), maximum_claim.as_str()); - assert_eq!(limitation.buyer_consequence(), maximum_consequence.as_str()); - - let oversized_claim = "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1); - assert_eq!( - DeclaredLimitation::new(oversized_claim.as_str(), "bounded buyer consequence"), - Err(ReleaseDecisionError::LimitationClaimTooLong) - ); - - let oversized_consequence = "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1); - assert_eq!( - DeclaredLimitation::new("bounded_claim", oversized_consequence.as_str()), - Err(ReleaseDecisionError::LimitationConsequenceTooLong) - ); - Ok(()) -} - -#[test] -fn borrowed_limitation_text_covers_every_validation_exit() { - assert_eq!( - DeclaredLimitation::new("", "bounded buyer consequence"), - Err(ReleaseDecisionError::EmptyLimitationClaim) - ); - assert_eq!( - DeclaredLimitation::new(" bounded_claim", "bounded buyer consequence"), - Err(ReleaseDecisionError::InvalidLimitationClaim) - ); - assert_eq!( - DeclaredLimitation::new("cafe\u{301}", "bounded buyer consequence"), - Err(ReleaseDecisionError::InvalidLimitationClaim) - ); - assert_eq!( - DeclaredLimitation::new("bounded_claim", ""), - Err(ReleaseDecisionError::EmptyLimitationConsequence) - ); - assert_eq!( - DeclaredLimitation::new("bounded_claim", "bounded buyer consequence "), - Err(ReleaseDecisionError::InvalidLimitationConsequence) - ); - assert_eq!( - DeclaredLimitation::new("bounded_claim", "cafe\u{301} buyer consequence"), - Err(ReleaseDecisionError::InvalidLimitationConsequence) - ); - assert_eq!( - DeclaredLimitation::new("forged\nclaim", "bounded buyer consequence"), - Err(ReleaseDecisionError::InvalidLimitationClaim) - ); - assert_eq!( - DeclaredLimitation::new("bounded_claim", "forged\nconsequence"), - Err(ReleaseDecisionError::InvalidLimitationConsequence) - ); -} - -#[test] -fn limitation_byte_budget_applies_to_international_text() { - let korean_character = "가"; - let repeated = - korean_character.repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES / korean_character.len() + 1); - assert!(repeated.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES); - assert_eq!( - DeclaredLimitation::new(repeated.as_str(), "지원 범위를 설명하는 구매자 안내"), - Err(ReleaseDecisionError::LimitationClaimTooLong) - ); -} - -#[test] -fn release_resource_limit_errors_have_deterministic_standard_error_contracts() { - let cases = [ - ( - ReleaseDecisionError::LimitationClaimTooLong, - "declared release limitation claim exceeds the byte budget", - ), - ( - ReleaseDecisionError::LimitationConsequenceTooLong, - "declared release limitation consequence exceeds the byte budget", - ), - ( - ReleaseDecisionError::TooManyDeclaredLimitations, - "benchmark release decision contains too many declared limitations", - ), - ]; - - for (error, expected_message) in cases { - assert_eq!(error.to_string(), expected_message); - let standard_error: &dyn std::error::Error = &error; - assert!(standard_error.source().is_none()); - } -} diff --git a/crates/originweave-core/tests/release_acceptance_unicode17.rs b/crates/originweave-core/tests/release_acceptance_unicode17.rs deleted file mode 100644 index eccd90e89..000000000 --- a/crates/originweave-core/tests/release_acceptance_unicode17.rs +++ /dev/null @@ -1,121 +0,0 @@ -use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; - -const UNICODE_17_DEFAULT_IGNORABLE_CODE_POINT_COUNT: usize = 4_174; - -#[test] -fn generic_constructor_input_shapes_cover_fail_closed_empty_boundaries() { - assert_eq!( - DeclaredLimitation::new(String::new(), "Linux ARM64 is unsupported."), - Err(ReleaseDecisionError::EmptyLimitationClaim), - ); - assert!( - DeclaredLimitation::new(String::from("linux_arm64"), "Linux ARM64 is unsupported.").is_ok() - ); - assert_eq!( - DeclaredLimitation::new("linux_arm64", String::new()), - Err(ReleaseDecisionError::EmptyLimitationConsequence), - ); - assert!( - DeclaredLimitation::new("linux_arm64", String::from("Linux ARM64 is unsupported.")).is_ok() - ); -} - -#[test] -fn limitation_rejects_unicode_17_default_ignorable_code_points() -> Result<(), &'static str> { - // Unicode 17.0.0 DerivedCoreProperties.txt (2025-07-30), - // Default_Ignorable_Code_Point. The reviewed ranges contain exactly 4,174 code points. - let ranges = [ - (0x00ad_u32, 0x00ad_u32), - (0x034f, 0x034f), - (0x061c, 0x061c), - (0x115f, 0x1160), - (0x17b4, 0x17b5), - (0x180b, 0x180f), - (0x200b, 0x200f), - (0x202a, 0x202e), - (0x2060, 0x206f), - (0x3164, 0x3164), - (0xfe00, 0xfe0f), - (0xfeff, 0xfeff), - (0xffa0, 0xffa0), - (0xfff0, 0xfff8), - (0x1bca0, 0x1bca3), - (0x1d173, 0x1d17a), - (0xe0000, 0xe0fff), - ]; - let mut tested_code_points = 0_usize; - - for (start, end) in ranges { - for code_point in start..=end { - let character = char::from_u32(code_point) - .ok_or("reviewed Unicode 17 default-ignorable range must contain scalar values")?; - tested_code_points += 1; - - assert_eq!( - DeclaredLimitation::new( - format!("linux_arm64{character}forged_release_claim"), - "Linux ARM64 is unsupported.", - ), - Err(ReleaseDecisionError::InvalidLimitationClaim), - "U+{code_point:04X} must be rejected in the unsupported claim", - ); - assert_eq!( - DeclaredLimitation::new( - "linux_arm64", - format!("Linux ARM64 is unsupported.{character}forged_release_consequence"), - ), - Err(ReleaseDecisionError::InvalidLimitationConsequence), - "U+{code_point:04X} must be rejected in the buyer consequence", - ); - } - } - - assert_eq!( - tested_code_points, UNICODE_17_DEFAULT_IGNORABLE_CODE_POINT_COUNT, - "reviewed Unicode 17 Default_Ignorable_Code_Point ranges must match the authoritative cardinality", - ); - Ok(()) -} - -#[test] -fn limitation_rejects_line_and_paragraph_separators_beyond_default_ignorable_set() { - for (name, separator) in [("U+2028", '\u{2028}'), ("U+2029", '\u{2029}')] { - assert_eq!( - DeclaredLimitation::new( - format!("linux_arm64{separator}forged_release_claim"), - "Linux ARM64 is unsupported.", - ), - Err(ReleaseDecisionError::InvalidLimitationClaim), - "{name} must be rejected in the unsupported claim to prevent line-forging ambiguity", - ); - assert_eq!( - DeclaredLimitation::new( - "linux_arm64", - format!("Linux ARM64 is unsupported.{separator}forged_release_consequence"), - ), - Err(ReleaseDecisionError::InvalidLimitationConsequence), - "{name} must be rejected in the buyer consequence to prevent line-forging ambiguity", - ); - } -} - -#[test] -fn limitation_does_not_blanket_reject_unicode_17_whitespace() -> Result<(), ReleaseDecisionError> { - let medium_mathematical_space = '\u{205f}'; - let ideographic_space = '\u{3000}'; - - let limitation = DeclaredLimitation::new( - format!("east{ideographic_space}asia"), - format!("Support is limited{medium_mathematical_space}to the declared profile."), - )?; - - assert_eq!( - limitation.unsupported_claim(), - format!("east{ideographic_space}asia") - ); - assert_eq!( - limitation.buyer_consequence(), - format!("Support is limited{medium_mathematical_space}to the declared profile.") - ); - Ok(()) -} diff --git a/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs new file mode 100644 index 000000000..360e8d4f1 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_accessibility_query.rs @@ -0,0 +1,195 @@ +use std::error::Error; + +use originweave_core::{ + MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES, MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, + MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES, UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, + WEBDRIVER_BIDI_LOCATE_NODES_METHOD, WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, + WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, +}; + +#[test] +fn accessibility_query_exposes_exact_bidi_method_and_locator_contract() -> Result<(), Box> +{ + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 32)?; + + assert_eq!(query.method(), WEBDRIVER_BIDI_LOCATE_NODES_METHOD); + assert_eq!(query.method(), "browsingContext.locateNodes"); + assert_eq!(query.locator_type(), "accessibility"); + assert_eq!(query.role(), Some("textbox")); + assert_eq!(query.name(), Some("Task text")); + assert_eq!(query.max_node_count(), 32); + Ok(()) +} + +#[test] +fn accessibility_query_fixes_minimal_serialization_options() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), None, 8)?; + + assert_eq!(WEBDRIVER_BIDI_QUERY_MAX_DOM_DEPTH, 0); + assert_eq!(WEBDRIVER_BIDI_QUERY_MAX_OBJECT_DEPTH, 0); + assert_eq!(WEBDRIVER_BIDI_QUERY_INCLUDE_SHADOW_TREE, "none"); + assert_eq!(query.serialization_max_dom_depth(), 0); + assert_eq!(query.serialization_max_object_depth(), 0); + assert_eq!(query.serialization_include_shadow_tree(), "none"); + Ok(()) +} + +#[test] +fn role_only_and_name_only_queries_are_valid() -> Result<(), Box> { + let role_only = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + assert_eq!(role_only.role(), Some("button")); + assert_eq!(role_only.name(), None); + + let name_only = WebDriverBiDiAccessibilityQuery::new(None, Some("Submit task"), 1)?; + assert_eq!(name_only.role(), None); + assert_eq!(name_only.name(), Some("Submit task")); + Ok(()) +} + +#[test] +fn missing_or_empty_accessibility_locator_fields_fail_closed() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, None, 1), + Err(WebDriverBiDiAccessibilityQueryError::MissingLocatorValue) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some(""), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::EmptyRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(""), 1), + Err(WebDriverBiDiAccessibilityQueryError::EmptyName) + ); +} + +#[test] +fn accessibility_role_rejects_whitespace_and_control_injection() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("text box"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button\n"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button\u{0000}"), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); +} + +#[test] +fn accessibility_locator_text_rejects_unicode_format_and_bidi_overrides() { + for character in UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS { + let role = format!("button{character}"); + let name = format!("Submit{character}task"); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some(&role), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidRole) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(&name), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); + } +} + +#[test] +fn accessibility_name_rejects_control_injection_and_whitespace_only_values() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\ntask"), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some("Submit\u{0000}task"), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(" "), 1), + Err(WebDriverBiDiAccessibilityQueryError::InvalidName) + ); +} + +#[test] +fn accessibility_name_keeps_ordinary_spaces_and_multibyte_text() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("작업 텍스트"), 1)?; + assert_eq!(query.role(), Some("textbox")); + assert_eq!(query.name(), Some("작업 텍스트")); + Ok(()) +} + +#[test] +fn accessibility_locator_text_is_bounded_by_utf8_bytes() -> Result<(), Box> { + let maximum_role = "r".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES); + let maximum_name = "n".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES); + let query = WebDriverBiDiAccessibilityQuery::new( + Some(&maximum_role), + Some(&maximum_name), + MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT, + )?; + assert_eq!(query.role(), Some(maximum_role.as_str())); + assert_eq!(query.name(), Some(maximum_name.as_str())); + + let overlong_role = "r".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_ROLE_BYTES + 1); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some(&overlong_role), None, 1), + Err(WebDriverBiDiAccessibilityQueryError::RoleTooLong) + ); + + let overlong_name = "n".repeat(MAX_BROWSER_ACCESSIBILITY_QUERY_NAME_BYTES + 1); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(None, Some(&overlong_name), 1), + Err(WebDriverBiDiAccessibilityQueryError::NameTooLong) + ); + Ok(()) +} + +#[test] +fn accessibility_query_node_count_is_finite_and_nonzero() { + assert_eq!( + WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 0), + Err(WebDriverBiDiAccessibilityQueryError::InvalidNodeCount) + ); + assert_eq!( + WebDriverBiDiAccessibilityQuery::new( + Some("button"), + None, + MAX_BROWSER_ACCESSIBILITY_QUERY_NODE_COUNT + 1, + ), + Err(WebDriverBiDiAccessibilityQueryError::InvalidNodeCount) + ); +} + +#[test] +fn accessibility_query_revalidates_returned_node_count() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 2)?; + + assert_eq!(query.validate_result_count(0), Ok(())); + assert_eq!(query.validate_result_count(2), Ok(())); + assert_eq!( + query.validate_result_count(3), + Err(WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded) + ); + Ok(()) +} + +#[test] +fn accessibility_query_error_contract_is_source_free() { + let errors = [ + WebDriverBiDiAccessibilityQueryError::MissingLocatorValue, + WebDriverBiDiAccessibilityQueryError::EmptyRole, + WebDriverBiDiAccessibilityQueryError::RoleTooLong, + WebDriverBiDiAccessibilityQueryError::EmptyName, + WebDriverBiDiAccessibilityQueryError::InvalidRole, + WebDriverBiDiAccessibilityQueryError::InvalidName, + WebDriverBiDiAccessibilityQueryError::NameTooLong, + WebDriverBiDiAccessibilityQueryError::InvalidNodeCount, + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, + ]; + + for error in errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs new file mode 100644 index 000000000..80add05b6 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_admission.rs @@ -0,0 +1,289 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiRemoteNodeReferenceError, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn controlled_origin() -> Origin { + Origin::parse("https://app.example").expect("valid controlled fixture origin") +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + expected_origin: &'a Origin, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, expected_origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + expected_origin, + ), + epoch, + )) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +#[test] +fn locate_nodes_result_binds_admitted_shared_ids_to_current_authority() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 2)?; + + let handles = query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[ + ("node", Some("shared-task-text")), + ("node", Some("shared-task-text-shadow")), + ], + )?; + + assert_eq!(handles.len(), 2); + assert_eq!( + handles[0].browser_session(), + target.context_origin().context().browser_session() + ); + assert_eq!( + handles[0].browsing_context(), + target.context_origin().context().browsing_context() + ); + assert_eq!(handles[0].origin(), &expected_origin); + assert_eq!(handles[0].document_epoch(), target.expected_epoch()); + assert_ne!(handles[0].node_id(), handles[1].node_id()); + handles[0].validate_current( + target.context_origin().context().browser_session(), + target.context_origin().context().browsing_context(), + &expected_origin, + target.expected_epoch(), + )?; + Ok(()) +} + +#[test] +fn over_budget_locate_nodes_result_fails_before_node_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[ + ("node", Some("shared-submit")), + ("node", Some("shared-extra")), + ], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::Query( + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded + )) + ); + Ok(()) +} + +#[test] +fn stale_document_epoch_fails_before_locate_nodes_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let stale_target = current_target(&mut registry, &expected_origin)?; + let context = stale_target.context_origin().context().browsing_context(); + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin( + stale_target.context_origin().context().browser_session(), + context, + &expected_origin, + )?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_ne!(current_epoch, stale_target.expected_epoch()); + assert_eq!( + query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + stale_target, + &[("node", Some("shared-submit"))], + ), + Err( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected: stale_target.expected_epoch(), + current: current_epoch, + } + ) + ); + Ok(()) +} + +#[test] +fn exhausted_node_identifier_space_fails_after_remote_value_admission() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 2)?; + + assert_eq!( + query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[ + ("node", Some("shared-submit")), + ("node", Some("shared-extra")), + ], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::IdentifierSpaceExhausted + )) + ); + Ok(()) +} + +#[test] +fn empty_locate_nodes_result_is_valid_when_the_document_is_current() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + let handles = + query.bind_current_nodes(semantic_observation_proof()?, &mut registry, target, &[])?; + assert!(handles.is_empty()); + Ok(()) +} + +#[test] +fn unknown_browser_session_fails_before_locate_nodes_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new( + BrowserSessionId::new(99).expect("nonzero fixture session"), + BrowsingContextId::new(7).expect("nonzero fixture context"), + ), + &expected_origin, + ), + DocumentEpoch::new(1).expect("nonzero fixture epoch"), + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::UnknownBrowserSession + )) + ); + Ok(()) +} + +#[test] +fn untrusted_non_node_item_fails_before_registry_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[("object", Some("shared-submit"))], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType + )) + ); + Ok(()) +} + +#[test] +fn locate_nodes_admission_error_contract_is_source_aware() { + let expected = DocumentEpoch::new(1).expect("nonzero fixture epoch"); + let current = DocumentEpoch::new(2).expect("nonzero fixture epoch"); + let errors = [ + WebDriverBiDiLocateNodesAdmissionError::Query( + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, + ), + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + ), + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { expected, current }, + WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::UnknownBrowserSession, + ), + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::TypedInput, + ), + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::Navigation, + ), + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::SemanticObservation, + ), + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::NetworkObservation, + ), + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + } + assert!(errors[0].source().is_some()); + assert!(errors[1].source().is_some()); + assert!(errors[2].source().is_none()); + assert!(errors[3].source().is_some()); + assert!(errors[4].source().is_none()); + assert!( + errors[4] + .to_string() + .contains("SemanticObservation protocol-use proof, not TypedInput") + ); + assert!(errors[5].to_string().contains("not Navigation")); + assert!(errors[6].to_string().contains("not SemanticObservation")); + assert!(errors[7].to_string().contains("not NetworkObservation")); +} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs new file mode 100644 index 000000000..974ea4d3f --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_atomicity.rs @@ -0,0 +1,88 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; +use std::io; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +#[test] +fn exhausted_locate_nodes_batch_does_not_consume_partial_node_authority() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let origin = Origin::parse("https://app.example").map_err(|_error| { + io::Error::new( + io::ErrorKind::InvalidData, + "controlled fixture origin must remain valid", + ) + })?; + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + let batch_query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 2)?; + + assert_eq!( + batch_query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[ + ("node", Some("shared-submit")), + ("node", Some("shared-extra")), + ], + ), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::IdentifierSpaceExhausted, + )) + ); + + let recovery_query = + WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Recovery action"), 1)?; + let handles = recovery_query.bind_current_nodes( + semantic_observation_proof()?, + &mut registry, + target, + &[("node", Some("shared-recovery"))], + )?; + + assert_eq!(handles.len(), 1); + assert_eq!(handles[0].node_id(), 1); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs new file mode 100644 index 000000000..d0195649b --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_command.rs @@ -0,0 +1,121 @@ +use std::error::Error; + +use originweave_core::{ + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, MAX_WEBDRIVER_BIDI_COMMAND_ID, + UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, WEBDRIVER_BIDI_LOCATE_NODES_METHOD, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesCommandError, +}; + +#[test] +fn locate_nodes_command_serializes_exact_bidi_envelope() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new( + Some("textbox"), + Some(r#"Task "quoted" \ review 작업"#), + 32, + )?; + let command = WebDriverBiDiLocateNodesCommand::new(42, r#"context-"quoted"\path"#, &query)?; + + assert_eq!(command.command_id(), 42); + assert_eq!(command.method(), WEBDRIVER_BIDI_LOCATE_NODES_METHOD); + assert_eq!(command.browsing_context(), r#"context-"quoted"\path"#); + assert_eq!( + command.as_json(), + r#"{"id":42,"method":"browsingContext.locateNodes","params":{"context":"context-\"quoted\"\\path","locator":{"type":"accessibility","value":{"role":"textbox","name":"Task \"quoted\" \\ review 작업"}},"maxNodeCount":32,"serializationOptions":{"maxDomDepth":0,"maxObjectDepth":0,"includeShadowTree":"none"}}}"# + ); + Ok(()) +} + +#[test] +fn locate_nodes_command_serializes_role_only_and_name_only_locators() -> Result<(), Box> +{ + let role_only = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + let role_command = WebDriverBiDiLocateNodesCommand::new(0, "context-a", &role_only)?; + assert_eq!( + role_command.as_json(), + r#"{"id":0,"method":"browsingContext.locateNodes","params":{"context":"context-a","locator":{"type":"accessibility","value":{"role":"button"}},"maxNodeCount":1,"serializationOptions":{"maxDomDepth":0,"maxObjectDepth":0,"includeShadowTree":"none"}}}"# + ); + + let name_only = WebDriverBiDiAccessibilityQuery::new(None, Some("Submit task"), 2)?; + let name_command = WebDriverBiDiLocateNodesCommand::new( + MAX_WEBDRIVER_BIDI_COMMAND_ID, + "context-b", + &name_only, + )?; + assert_eq!(name_command.command_id(), MAX_WEBDRIVER_BIDI_COMMAND_ID); + assert_eq!( + name_command.as_json(), + r#"{"id":9007199254740991,"method":"browsingContext.locateNodes","params":{"context":"context-b","locator":{"type":"accessibility","value":{"name":"Submit task"}},"maxNodeCount":2,"serializationOptions":{"maxDomDepth":0,"maxObjectDepth":0,"includeShadowTree":"none"}}}"# + ); + Ok(()) +} + +#[test] +fn locate_nodes_command_rejects_out_of_range_command_id() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + WebDriverBiDiLocateNodesCommand::new( + MAX_WEBDRIVER_BIDI_COMMAND_ID + 1, + "context-a", + &query, + ), + Err(WebDriverBiDiLocateNodesCommandError::InvalidCommandId) + ); + assert_eq!( + WebDriverBiDiLocateNodesCommand::new(u64::MAX, "context-a", &query), + Err(WebDriverBiDiLocateNodesCommandError::InvalidCommandId) + ); + Ok(()) +} + +#[test] +fn locate_nodes_command_rejects_invalid_browsing_context_text() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + for invalid_context in ["", "context with space", "context\nline"] { + assert_eq!( + WebDriverBiDiLocateNodesCommand::new(1, invalid_context, &query), + Err(WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext) + ); + } + + let overlong = "c".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1); + assert_eq!( + WebDriverBiDiLocateNodesCommand::new(1, &overlong, &query), + Err(WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext) + ); + + for character in UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS { + let context = format!("context{character}"); + assert_eq!( + WebDriverBiDiLocateNodesCommand::new(1, &context, &query), + Err(WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext) + ); + } + Ok(()) +} + +#[test] +fn locate_nodes_command_accepts_maximum_bounded_context() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + let context = "c".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); + let command = WebDriverBiDiLocateNodesCommand::new(1, &context, &query)?; + + assert_eq!(command.browsing_context(), context); + assert!(command.as_json().contains(&context)); + Ok(()) +} + +#[test] +fn locate_nodes_command_error_contract_is_source_free() { + let errors = [ + WebDriverBiDiLocateNodesCommandError::InvalidCommandId, + WebDriverBiDiLocateNodesCommandError::InvalidBrowsingContext, + ]; + + for error in errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs new file mode 100644 index 000000000..a8147feb4 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_correlation.rs @@ -0,0 +1,70 @@ +use std::error::Error; + +use originweave_core::{ + MAX_WEBDRIVER_BIDI_COMMAND_ID, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseCorrelationError, +}; + +fn locate_nodes_command( + command_id: u64, +) -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 4)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + command_id, + "context-a", + &query, + )?) +} + +#[test] +fn locate_nodes_response_requires_exact_command_id() -> Result<(), Box> { + let correlated = locate_nodes_command(42)?.correlate_response_id(42)?; + + assert_eq!(correlated.command_id(), 42); + assert_eq!(correlated.browsing_context(), "context-a"); + Ok(()) +} + +#[test] +fn locate_nodes_response_rejects_mismatched_command_id() -> Result<(), Box> { + let error = locate_nodes_command(42)?.correlate_response_id(41); + + assert_eq!( + error, + Err( + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 42, + actual: 41, + } + ) + ); + Ok(()) +} + +#[test] +fn locate_nodes_response_rejects_out_of_range_id_before_correlation() -> Result<(), Box> +{ + let error = locate_nodes_command(1)?.correlate_response_id(MAX_WEBDRIVER_BIDI_COMMAND_ID + 1); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId) + ); + Ok(()) +} + +#[test] +fn response_correlation_error_contract_is_source_free() { + let errors = [ + WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId, + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 2, + actual: 1, + }, + ]; + + for error in errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs new file mode 100644 index 000000000..811def1f3 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_document.rs @@ -0,0 +1,100 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiLocateNodesResponseEnvelopeError, + WebDriverBiDiResponseEnvelopeParseError, +}; + +fn locate_nodes_command( + command_id: u64, +) -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 4)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + command_id, + "context-a", + &query, + )?) +} + +#[test] +fn bounded_success_document_is_parsed_and_correlated_in_one_consuming_boundary() +-> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[]}}"#, + )?; + let correlated = locate_nodes_command(42)?.correlate_response_document(document)?; + + assert_eq!(correlated.kind(), WebDriverBiDiCommandResponseKind::Success); + assert_eq!(correlated.command_id(), 42); + assert_eq!(correlated.browsing_context(), "context-a"); + Ok(()) +} + +#[test] +fn malformed_bounded_document_preserves_the_parser_failure() -> Result<(), Box> { + let document = + BoundedWebDriverBiDiResponseDocument::new(r#"{"type":"success","id":42,"result":{},}"#)?; + let result = locate_nodes_command(42)?.correlate_response_document(document); + + assert_eq!( + result, + Err(WebDriverBiDiLocateNodesResponseDocumentError::Parse( + WebDriverBiDiResponseEnvelopeParseError::InvalidJson, + )) + ); + Ok(()) +} + +#[test] +fn parsed_response_id_mismatch_preserves_exact_correlation_failure() -> Result<(), Box> { + let document = + BoundedWebDriverBiDiResponseDocument::new(r#"{"type":"success","id":41,"result":{}}"#)?; + let result = locate_nodes_command(42)?.correlate_response_document(document); + + assert_eq!( + result, + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 42, + actual: 41, + }, + ), + )) + ); + Ok(()) +} + +#[test] +fn nullable_error_document_remains_explicitly_uncorrelatable() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"error","id":null,"error":"invalid argument","message":"bad request"}"#, + )?; + let result = locate_nodes_command(42)?.correlate_response_document(document); + + assert_eq!( + result, + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + )) + ); + Ok(()) +} + +#[test] +fn document_correlation_error_preserves_nested_error_sources() { + let parse = WebDriverBiDiLocateNodesResponseDocumentError::Parse( + WebDriverBiDiResponseEnvelopeParseError::InvalidJson, + ); + assert!(parse.source().is_some()); + assert!(!parse.to_string().is_empty()); + + let envelope = WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::MissingResponseId, + ); + assert!(envelope.source().is_some()); + assert!(!envelope.to_string().is_empty()); +} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs new file mode 100644 index 000000000..4425be6ee --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_response_envelope.rs @@ -0,0 +1,141 @@ +use std::error::Error; + +use originweave_core::{ + WebDriverBiDiAccessibilityQuery, WebDriverBiDiAccessibilityQueryError, + WebDriverBiDiCommandResponseKind, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseEnvelopeError, +}; + +fn locate_nodes_command( + command_id: u64, +) -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 4)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + command_id, + "context-a", + &query, + )?) +} + +#[test] +fn success_envelope_requires_and_retains_exact_response_id() -> Result<(), Box> { + let correlated = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))?; + + assert_eq!(correlated.kind(), WebDriverBiDiCommandResponseKind::Success); + assert_eq!(correlated.command_id(), 42); + assert_eq!(correlated.browsing_context(), "context-a"); + Ok(()) +} + +#[test] +fn correlated_success_can_be_consumed_as_success_evidence() -> Result<(), Box> { + let validated = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))? + .into_validated_success()?; + + assert_eq!(validated.command_id(), 42); + assert_eq!(validated.browsing_context(), "context-a"); + Ok(()) +} + +#[test] +fn correlated_success_enforces_exact_serialized_result_budget() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 1)?; + let validated = WebDriverBiDiLocateNodesCommand::new(42, "context-a", &query)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))? + .into_validated_success()?; + + assert_eq!(validated.max_node_count(), 1); + assert_eq!(validated.validate_result_count(1), Ok(())); + assert_eq!( + validated.validate_result_count(2), + Err(WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded) + ); + Ok(()) +} + +#[test] +fn error_envelope_with_id_is_correlated_but_remains_error_kind() -> Result<(), Box> { + let correlated = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Error, Some(42))?; + + assert_eq!(correlated.kind(), WebDriverBiDiCommandResponseKind::Error); + assert_eq!(correlated.command_id(), 42); + assert_eq!(correlated.browsing_context(), "context-a"); + Ok(()) +} + +#[test] +fn correlated_error_cannot_become_success_evidence() -> Result<(), Box> { + let result = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Error, Some(42))? + .into_validated_success(); + + assert_eq!( + result, + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::CorrelatedErrorResponse) + ); + Ok(()) +} + +#[test] +fn success_envelope_rejects_missing_id() -> Result<(), Box> { + let error = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, None); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::MissingResponseId) + ); + Ok(()) +} + +#[test] +fn null_error_id_is_explicitly_uncorrelatable() -> Result<(), Box> { + let error = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Error, None); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse) + ); + Ok(()) +} + +#[test] +fn envelope_preserves_exact_correlation_failures() -> Result<(), Box> { + let error = locate_nodes_command(42)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(41)); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 42, + actual: 41, + } + )) + ); + Ok(()) +} + +#[test] +fn envelope_error_sources_distinguish_protocol_shape_from_correlation() { + let direct_errors = [ + WebDriverBiDiLocateNodesResponseEnvelopeError::MissingResponseId, + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + WebDriverBiDiLocateNodesResponseEnvelopeError::CorrelatedErrorResponse, + ]; + for error in direct_errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } + + let correlation = WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::InvalidResponseId, + ); + assert!(correlation.source().is_some()); + assert!(!correlation.to_string().is_empty()); +} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs new file mode 100644 index 000000000..5c70cdc7a --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_result_admission.rs @@ -0,0 +1,297 @@ +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, BrowserRegistryError, Origin, + OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiAccessibilityQueryError, WebDriverBiDiCommandResponseKind, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiLocateNodesCommand, + WebDriverBiDiLocateNodesResultAdmissionError, WebDriverBiDiRemoteNodeReferenceError, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn correlated_success( + max_node_count: u16, +) -> Result> { + let query = + WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), max_node_count)?; + Ok( + WebDriverBiDiLocateNodesCommand::new(42, "context-a", &query)? + .correlate_response_envelope(WebDriverBiDiCommandResponseKind::Success, Some(42))? + .into_validated_success()?, + ) +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + origin: &'a Origin, + external_context: &str, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, external_context)?; + let epoch = registry.bind_context_origin(session, context, origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + origin, + ), + epoch, + )) +} + +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn protocol_proof( + kind: BrowserProtocolKind, + capability: BrowserProtocolCapability, +) -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + kind, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[capability], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + kind, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capability, + )?) +} + +fn semantic_observation_proof() -> Result> { + protocol_proof( + BrowserProtocolKind::WebDriverBiDi, + BrowserProtocolCapability::SemanticObservation, + ) +} + +#[test] +fn correlated_result_admission_retains_exact_command_and_normalized_nodes() +-> Result<(), Box> { + let result = correlated_success(2)?.admit_result_nodes(&[ + ("node", Some("shared-node-a")), + ("node", Some("shared-node-b")), + ])?; + + assert_eq!(result.command_id(), 42); + assert_eq!(result.browsing_context(), "context-a"); + assert_eq!(result.max_node_count(), 2); + assert_eq!(result.nodes().len(), 2); + assert_eq!(result.nodes()[0].remote_type(), "node"); + assert_eq!(result.nodes()[0].shared_id(), "shared-node-a"); + assert_eq!(result.nodes()[1].shared_id(), "shared-node-b"); + Ok(()) +} + +#[test] +fn correlated_result_admission_rejects_over_budget_batch_before_node_normalization() +-> Result<(), Box> { + let error = + correlated_success(1)?.admit_result_nodes(&[("not-a-node", None), ("not-a-node", None)]); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResultAdmissionError::Query( + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, + )) + ); + Ok(()) +} + +#[test] +fn correlated_result_admission_rejects_invalid_remote_node_shape() -> Result<(), Box> { + let error = correlated_success(1)?.admit_result_nodes(&[("string", Some("shared-node-a"))]); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResultAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType, + )) + ); + Ok(()) +} + +#[test] +fn correlated_result_admission_error_preserves_typed_source() { + let query_error = WebDriverBiDiLocateNodesResultAdmissionError::Query( + WebDriverBiDiAccessibilityQueryError::ResultNodeCountExceeded, + ); + assert!(query_error.source().is_some()); + assert!(!query_error.to_string().is_empty()); + + let remote_error = WebDriverBiDiLocateNodesResultAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + ); + assert!(remote_error.source().is_some()); + assert!(!remote_error.to_string().is_empty()); +} + +#[test] +fn correlated_result_binds_only_to_its_exact_registered_context() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + let result = correlated_success(2)?.admit_result_nodes(&[ + ("node", Some("shared-node-a")), + ("node", Some("shared-node-b")), + ])?; + + let handles = + result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target)?; + + assert_eq!(handles.len(), 2); + assert_eq!( + handles[0].browsing_context(), + target.context_origin().context().browsing_context() + ); + assert_eq!(handles[0].origin(), &origin); + assert_eq!(handles[0].document_epoch(), target.expected_epoch()); + Ok(()) +} + +#[test] +fn correlated_result_rejects_cross_context_rebinding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-b")?; + let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; + + let error = result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::ContextExternalIdentifierMismatch, + )) + ); + let error = error.err().ok_or("expected context mismatch")?; + assert!(error.to_string().contains("external identifier")); + Ok(()) +} + +#[test] +fn correlated_result_rejects_non_bidi_protocol_proof() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; + + assert_eq!( + result.bind_current_nodes( + protocol_proof( + BrowserProtocolKind::ChromeDevToolsProtocol, + BrowserProtocolCapability::SemanticObservation, + )?, + &mut registry, + target, + ), + Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind( + BrowserProtocolKind::ChromeDevToolsProtocol, + ) + ) + ); + Ok(()) +} + +#[test] +fn correlated_result_rejects_non_observation_protocol_proof() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; + + assert_eq!( + result.bind_current_nodes( + protocol_proof( + BrowserProtocolKind::WebDriverBiDi, + BrowserProtocolCapability::TypedInput, + )?, + &mut registry, + target, + ), + Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::TypedInput, + ) + ) + ); + Ok(()) +} + +#[test] +fn correlated_result_rejects_missing_current_origin_binding() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + let context = target.context_origin().context().browsing_context(); + registry.advance_document(context)?; + let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; + + assert_eq!( + result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::ContextOriginNotBound, + )) + ); + Ok(()) +} + +#[test] +fn correlated_result_rejects_stale_document_epoch() -> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + let context = target.context_origin().context().browsing_context(); + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin( + target.context_origin().context().browser_session(), + context, + &origin, + )?; + let result = correlated_success(1)?.admit_result_nodes(&[("node", Some("shared-node-a"))])?; + + assert_eq!( + result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target), + Err( + WebDriverBiDiLocateNodesAdmissionError::DocumentEpochMismatch { + expected: target.expected_epoch(), + current: current_epoch, + } + ) + ); + Ok(()) +} + +#[test] +fn correlated_result_keeps_node_binding_transactional_on_identifier_exhaustion() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::with_identifier_limit(1); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + let result = correlated_success(2)?.admit_result_nodes(&[ + ("node", Some("shared-node-a")), + ("node", Some("shared-node-b")), + ])?; + + assert_eq!( + result.bind_current_nodes(semantic_observation_proof()?, &mut registry, target), + Err(WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::IdentifierSpaceExhausted, + )) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs new file mode 100644 index 000000000..aba667210 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_locate_nodes_wire_result.rs @@ -0,0 +1,209 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, WebDriverBiDiErrorCode, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, +}; + +fn locate_nodes_command( + command_id: u64, + max_node_count: u16, +) -> Result> { + let query = + WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), max_node_count)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + command_id, + "context-a", + &query, + )?) +} + +#[test] +fn bounded_locate_nodes_document_admits_exact_wire_nodes_without_caller_selected_payload() +-> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"ignored":[true,false,null],"nodes":[{"type":"node","sharedId":"node-a"},{"type":"node","sharedId":"node-b","value":{"nodeType":1}}]}}"#, + )?; + let admitted = locate_nodes_command(42, 2)?.admit_response_document_nodes(document)?; + + assert_eq!(admitted.command_id(), 42); + assert_eq!(admitted.browsing_context(), "context-a"); + assert_eq!(admitted.max_node_count(), 2); + assert_eq!(admitted.nodes().len(), 2); + assert_eq!(admitted.nodes()[0].remote_type(), "node"); + assert_eq!(admitted.nodes()[0].shared_id(), "node-a"); + assert_eq!(admitted.nodes()[1].shared_id(), "node-b"); + Ok(()) +} + +#[test] +fn wire_result_preserves_exact_command_node_budget() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":"node","sharedId":"node-b"}]}}"#, + )?; + + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + Ok(()) +} + +#[test] +fn wire_result_checks_node_budget_before_parsing_overflow_items() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":1,"sharedId":"malformed-overflow-item"}]}}"#, + )?; + + assert!(matches!( + locate_nodes_command(42, 1)?.admit_response_document_nodes(document), + Err(WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission(_)) + )); + Ok(()) +} + +#[test] +fn wire_result_rejects_missing_shared_id() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node"}]}}"#, + )?; + + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + Ok(()) +} + +#[test] +fn wire_result_rejects_non_node_remote_value() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"window","sharedId":"node-a"}]}}"#, + )?; + + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + Ok(()) +} + +#[test] +fn wire_result_requires_nodes_array() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":42,"result":{}}"#, + r#"{"type":"success","id":42,"result":{"nodes":{}}}"#, + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + } + Ok(()) +} + +#[test] +fn wire_result_rejects_ambiguous_duplicate_result_or_node_fields() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":42,"result":{"nodes":[],"nodes":[]}}"#, + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","type":"node","sharedId":"node-a"}]}}"#, + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a","sharedId":"node-a"}]}}"#, + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert!( + locate_nodes_command(42, 1)? + .admit_response_document_nodes(document) + .is_err() + ); + } + Ok(()) +} + +#[test] +fn wire_result_decodes_json_escaped_protocol_fields_before_admission() -> Result<(), Box> +{ + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"res\u0075lt":{"no\u0064es":[{"ty\u0070e":"no\u0064e","shared\u0049d":"node-\u03b1"}]}}"#, + )?; + let admitted = locate_nodes_command(42, 1)?.admit_response_document_nodes(document)?; + + assert_eq!(admitted.nodes().len(), 1); + assert_eq!(admitted.nodes()[0].shared_id(), "node-α"); + Ok(()) +} + +#[test] +fn wire_result_boundary_preserves_parse_correlation_and_protocol_failures() +-> Result<(), Box> { + let malformed = + BoundedWebDriverBiDiResponseDocument::new(r#"{"type":"success","id":42,"result":{},}"#)?; + assert!(matches!( + locate_nodes_command(42, 1)?.admit_response_document_nodes(malformed), + Err(WebDriverBiDiLocateNodesResponseDocumentError::Parse(_)) + )); + + let mismatched = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":41,"result":{"nodes":[]}}"#, + )?; + assert!(matches!( + locate_nodes_command(42, 1)?.admit_response_document_nodes(mismatched), + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope(_)) + )); + + let error_response = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"error","id":42,"error":"invalid argument","message":"bad request"}"#, + )?; + assert_eq!( + locate_nodes_command(42, 1)?.admit_response_document_nodes(error_response), + Err( + WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( + WebDriverBiDiErrorCode::InvalidArgument, + ) + ) + ); + Ok(()) +} + +#[test] +fn wire_result_document_error_display_and_sources_cover_result_failure_variants() +-> Result<(), Box> { + let source_free = [ + WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( + WebDriverBiDiErrorCode::InvalidArgument, + ), + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodes, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodes, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodes, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNode, + WebDriverBiDiLocateNodesResponseDocumentError::DuplicateResultNodeField, + WebDriverBiDiLocateNodesResponseDocumentError::MissingResultNodeType, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeType, + WebDriverBiDiLocateNodesResponseDocumentError::InvalidResultNodeSharedId, + WebDriverBiDiLocateNodesResponseDocumentError::ResultParserInvariant, + ]; + for error in source_free { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } + + let over_budget = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"},{"type":"node","sharedId":"node-b"}]}}"#, + )?; + let result = locate_nodes_command(42, 1)?.admit_response_document_nodes(over_budget); + let error = match result { + Err(error @ WebDriverBiDiLocateNodesResponseDocumentError::ResultAdmission(_)) => error, + _ => { + return Err( + "over-budget wire result must preserve result-admission error evidence".into(), + ); + } + }; + assert!(!error.to_string().is_empty()); + assert!(error.source().is_some()); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs b/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs new file mode 100644 index 000000000..a138cab4e --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_protocol_error_preservation.rs @@ -0,0 +1,44 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, WebDriverBiDiAccessibilityQuery, WebDriverBiDiErrorCode, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseDocumentError, + WebDriverBiDiLocateNodesResponseEnvelopeError, +}; + +#[test] +fn correlated_wire_error_remains_typed_through_node_admission() -> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 4)?; + let command = WebDriverBiDiLocateNodesCommand::new(42, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"error","id":42,"error":"unavailable network data","message":"retry later"}"#, + )?; + + assert_eq!( + command.admit_response_document_nodes(document), + Err( + WebDriverBiDiLocateNodesResponseDocumentError::ProtocolError( + WebDriverBiDiErrorCode::UnavailableNetworkData, + ) + ) + ); + Ok(()) +} + +#[test] +fn nullable_wire_error_remains_uncorrelatable_before_protocol_error_admission() +-> Result<(), Box> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 4)?; + let command = WebDriverBiDiLocateNodesCommand::new(42, "context-a", &query)?; + let document = BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"error","id":null,"error":"invalid argument","message":"bad request"}"#, + )?; + + assert_eq!( + command.admit_response_document_nodes(document), + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::UncorrelatableErrorResponse, + )) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs b/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs new file mode 100644 index 000000000..8c764060d --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_protocol_kind_admission.rs @@ -0,0 +1,75 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserProtocolAdapterDescriptor, + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const CDP_ADAPTER_VERSION: &str = "originweave-cdp-v1"; +const CDP_PROTOCOL_REVISION: &str = "cdp-pdl-2026-08-17"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn cdp_semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::ChromeDevToolsProtocol, + ORIGINWEAVE_PROTOCOL_VERSION, + CDP_ADAPTER_VERSION, + CDP_PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::ChromeDevToolsProtocol, + CDP_ADAPTER_VERSION, + CDP_PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +#[test] +fn webdriver_bidi_locate_nodes_rejects_cdp_semantic_observation_proof() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + let origin = Origin::parse("https://app.example").expect("valid controlled fixture origin"); + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, &origin)?; + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + &origin, + ), + epoch, + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 1)?; + + let error = query + .bind_current_nodes( + cdp_semantic_observation_proof()?, + &mut registry, + target, + &[("node", Some("shared-task-text"))], + ) + .expect_err("CDP proof must not authorize WebDriver BiDi locateNodes admission"); + assert_eq!( + error, + WebDriverBiDiLocateNodesAdmissionError::UnsupportedProtocolKind( + BrowserProtocolKind::ChromeDevToolsProtocol, + ) + ); + assert_eq!( + error.to_string(), + "locateNodes admission requires a WebDriverBiDi protocol-use proof, not ChromeDevToolsProtocol" + ); + assert!(error.source().is_none()); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs new file mode 100644 index 000000000..69f407aa2 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_query_nodes_admission.rs @@ -0,0 +1,365 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_core::{ + BrowserAuthorityRegistry, BrowserContextDispatchTarget, BrowserContextOriginDispatchTarget, + BrowserContextOriginEpochDispatchTarget, BrowserContextProtocolDispatchError, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, + BrowserProtocolCapabilityRequirementError, BrowserProtocolKind, BrowserProtocolOperation, + BrowserProtocolRuntimeMetadata, BrowserProtocolUseValidationError, BrowserSessionId, + BrowsingContextId, DocumentEpoch, ObservedNodeHandle, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, WebDriverBiDiAccessibilityQuery, + WebDriverBiDiLocateNodesAdmissionError, WebDriverBiDiQueryNodesAdmissionError, + WebDriverBiDiRemoteNodeReferenceError, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn descriptor( + capabilities: &[BrowserProtocolCapability], +) -> Result> { + Ok(BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capabilities, + )?) +} + +fn runtime_metadata() -> BrowserProtocolRuntimeMetadata<'static> { + BrowserProtocolRuntimeMetadata::new( + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + ) +} + +fn controlled_origin() -> Origin { + Origin::parse("https://app.example").expect("valid controlled fixture origin") +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + expected_origin: &'a Origin, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, "top-level-context")?; + let epoch = registry.bind_context_origin(session, context, expected_origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + expected_origin, + ), + epoch, + )) +} + +fn admit_query_nodes<'a>( + descriptor: &BrowserProtocolAdapterDescriptor, + registry: &mut BrowserAuthorityRegistry, + target: BrowserContextOriginEpochDispatchTarget<'a>, + query: &WebDriverBiDiAccessibilityQuery, + items: &[(&str, Option<&str>)], +) -> Result, WebDriverBiDiQueryNodesAdmissionError> { + descriptor.admit_query_nodes( + registry, + target, + ORIGINWEAVE_PROTOCOL_VERSION, + runtime_metadata(), + query, + items, + ) +} + +#[test] +fn query_nodes_admission_requires_semantic_observation_and_binds_current_handles() +-> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("textbox"), Some("Task text"), 2)?; + + let handles = admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[ + ("node", Some("shared-task-text")), + ("node", Some("shared-task-text-shadow")), + ], + )?; + + assert_eq!(handles.len(), 2); + assert_eq!( + handles[0].browser_session(), + target.context_origin().context().browser_session() + ); + assert_eq!( + handles[0].browsing_context(), + target.context_origin().context().browsing_context() + ); + assert_eq!(handles[0].origin(), &expected_origin); + assert_eq!(handles[0].document_epoch(), target.expected_epoch()); + assert_ne!(handles[0].node_id(), handles[1].node_id()); + handles[0].validate_current( + target.context_origin().context().browser_session(), + target.context_origin().context().browsing_context(), + &expected_origin, + target.expected_epoch(), + )?; + Ok(()) +} + +#[test] +fn navigation_only_adapter_cannot_admit_query_nodes() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::Navigation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::Capability( + BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + BrowserProtocolCapability::SemanticObservation, + ), + ), + ), + )) + ); + Ok(()) +} + +#[test] +fn typed_input_only_adapter_cannot_admit_query_nodes() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::TypedInput])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::ProtocolValidation( + BrowserProtocolUseValidationError::Capability( + BrowserProtocolCapabilityRequirementError::UnsupportedCapability( + BrowserProtocolCapability::SemanticObservation, + ), + ), + ), + )) + ); + Ok(()) +} + +fn protocol_use_proof( + descriptor: &BrowserProtocolAdapterDescriptor, + capability: BrowserProtocolCapability, +) -> Result> { + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + capability, + )?) +} + +#[test] +fn bind_current_nodes_rejects_typed_input_and_navigation_protocol_proofs() +-> Result<(), Box> { + let typed_input = descriptor(&[BrowserProtocolCapability::TypedInput])?; + let navigation = descriptor(&[BrowserProtocolCapability::Navigation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + let items = [("node", Some("shared-submit"))]; + + assert_eq!( + query.bind_current_nodes( + protocol_use_proof(&typed_input, BrowserProtocolCapability::TypedInput)?, + &mut registry, + target, + &items, + ), + Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::TypedInput + ) + ) + ); + assert_eq!( + query.bind_current_nodes( + protocol_use_proof(&navigation, BrowserProtocolCapability::Navigation)?, + &mut registry, + target, + &items, + ), + Err( + WebDriverBiDiLocateNodesAdmissionError::UnsupportedCapability( + BrowserProtocolCapability::Navigation + ) + ) + ); + Ok(()) +} + +#[test] +fn query_nodes_admission_rejects_control_bearing_and_omitted_shared_ids() +-> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = current_target(&mut registry, &expected_origin)?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit\n"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::LocateNodes( + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId + ) + )) + ); + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", None)], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::LocateNodes( + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId + ) + )) + ); + Ok(()) +} + +#[test] +fn query_nodes_admission_fails_closed_on_stale_document_epoch() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let stale_target = current_target(&mut registry, &expected_origin)?; + let context = stale_target.context_origin().context().browsing_context(); + let current_epoch = registry.advance_document(context)?; + registry.bind_context_origin( + stale_target.context_origin().context().browser_session(), + context, + &expected_origin, + )?; + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert_ne!(current_epoch, stale_target.expected_epoch()); + assert_eq!( + admit_query_nodes( + &descriptor, + &mut registry, + stale_target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::DocumentEpochMismatch { + expected: stale_target.expected_epoch(), + current: current_epoch, + } + )) + ); + Ok(()) +} + +#[test] +fn query_nodes_admission_fails_closed_on_unknown_session() -> Result<(), Box> { + let descriptor = descriptor(&[BrowserProtocolCapability::SemanticObservation])?; + let mut registry = BrowserAuthorityRegistry::new(); + let expected_origin = controlled_origin(); + let target = BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new( + BrowserSessionId::new(99).expect("nonzero fixture session"), + BrowsingContextId::new(7).expect("nonzero fixture context"), + ), + &expected_origin, + ), + DocumentEpoch::new(1).expect("nonzero fixture epoch"), + ); + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), None, 1)?; + + assert!(matches!( + admit_query_nodes( + &descriptor, + &mut registry, + target, + &query, + &[("node", Some("shared-submit"))], + ), + Err(WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::BrowserAuthority(_) + )) + )); + Ok(()) +} + +#[test] +fn query_nodes_maps_to_semantic_observation_and_error_contract_is_source_aware() { + assert_eq!( + BrowserProtocolOperation::QueryNodes.required_capability(), + BrowserProtocolCapability::SemanticObservation + ); + + let expected = DocumentEpoch::new(1).expect("nonzero fixture epoch"); + let current = DocumentEpoch::new(2).expect("nonzero fixture epoch"); + let errors = [ + WebDriverBiDiQueryNodesAdmissionError::ProtocolDispatch( + BrowserContextProtocolDispatchError::DocumentEpochMismatch { expected, current }, + ), + WebDriverBiDiQueryNodesAdmissionError::LocateNodes( + WebDriverBiDiLocateNodesAdmissionError::RemoteNode( + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + ), + ), + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_some()); + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs new file mode 100644 index 000000000..d5f25bfbc --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_remote_node_reference.rs @@ -0,0 +1,110 @@ +use std::error::Error; + +use originweave_core::{ + MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES, UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS, + WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE, WebDriverBiDiRemoteNodeReference, + WebDriverBiDiRemoteNodeReferenceError, +}; + +#[test] +fn remote_node_reference_requires_exact_node_type_and_shared_id() -> Result<(), Box> { + let reference = WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42"))?; + + assert_eq!( + reference.remote_type(), + WEBDRIVER_BIDI_NODE_REMOTE_VALUE_TYPE + ); + assert_eq!(reference.remote_type(), "node"); + assert_eq!(reference.shared_id(), "shared-node-42"); + Ok(()) +} + +#[test] +fn remote_node_reference_rejects_non_node_remote_values() { + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("object", Some("shared-node-42")), + Err(WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType) + ); +} + +#[test] +fn remote_node_reference_requires_a_usable_shared_id() { + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", None), + Err(WebDriverBiDiRemoteNodeReferenceError::MissingSharedId) + ); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); +} + +#[test] +fn remote_node_reference_rejects_unicode_format_and_bidi_overrides() { + for character in UNICODE_PROTOCOL_FORMAT_INJECTION_CHARS { + let shared_id = format!("shared-node-42{character}"); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(&shared_id)), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + } +} + +#[test] +fn remote_node_reference_rejects_whitespace_and_control_injection() { + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(" ")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\n")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some("shared-node-42\u{0000}")), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); +} + +#[test] +fn remote_node_reference_reuses_the_registry_identifier_budget() -> Result<(), Box> { + let maximum = "n".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); + let reference = WebDriverBiDiRemoteNodeReference::new("node", Some(&maximum))?; + assert_eq!(reference.shared_id(), maximum); + + let overlong = "n".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES + 1); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(&overlong)), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + Ok(()) +} + +#[test] +fn remote_node_reference_bounds_multibyte_shared_ids_by_utf8_bytes() -> Result<(), Box> { + let exact = "한".repeat(MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES / "한".len()); + let reference = WebDriverBiDiRemoteNodeReference::new("node", Some(&exact))?; + assert_eq!(reference.shared_id(), exact); + + let overlong = format!("{exact}한"); + assert!(overlong.len() > MAX_EXTERNAL_BROWSER_IDENTIFIER_BYTES); + assert_eq!( + WebDriverBiDiRemoteNodeReference::new("node", Some(&overlong)), + Err(WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId) + ); + Ok(()) +} + +#[test] +fn remote_node_reference_error_contract_is_source_free() { + let errors = [ + WebDriverBiDiRemoteNodeReferenceError::UnexpectedRemoteType, + WebDriverBiDiRemoteNodeReferenceError::MissingSharedId, + WebDriverBiDiRemoteNodeReferenceError::InvalidSharedId, + ]; + + for error in errors { + assert!(error.source().is_none()); + assert!(!error.to_string().is_empty()); + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs new file mode 100644 index 000000000..8d440272e --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_document_budget.rs @@ -0,0 +1,98 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES, + WebDriverBiDiResponseDocumentAdmissionError, +}; + +#[test] +fn bounded_response_document_retains_exact_wire_text() -> Result<(), Box> { + let raw = " \r\n{\"id\":42,\"type\":\"success\",\"result\":{}}\t"; + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + + assert_eq!(document.as_str(), raw); + Ok(()) +} + +#[test] +fn bounded_response_document_admits_transport_bytes_without_preallocating_untrusted_text() +-> Result<(), Box> { + let raw = b" \r\n{\"id\":42,\"type\":\"success\",\"result\":{}}\t"; + let document = BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(raw)?; + + assert_eq!(document.as_str(), std::str::from_utf8(raw)?); + + let invalid_utf8 = [b'{', 0xff, b'}']; + assert_eq!( + BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(&invalid_utf8), + Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8) + ); + + let oversized_invalid_utf8 = vec![0xff; MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1]; + assert_eq!( + BoundedWebDriverBiDiResponseDocument::from_utf8_bytes(&oversized_invalid_utf8), + Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge) + ); + Ok(()) +} + +#[test] +fn empty_or_json_whitespace_only_response_document_fails_closed() { + for raw in ["", " ", "\t\r\n"] { + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(raw), + Err(WebDriverBiDiResponseDocumentAdmissionError::EmptyDocument) + ); + } +} + +#[test] +fn response_document_requires_an_object_boundary_without_claiming_json_validation() +-> Result<(), Box> { + for raw in ["[]", "null", "{", "}", "\u{00a0}{}\u{00a0}"] { + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(raw), + Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary) + ); + } + + let coarse_only = BoundedWebDriverBiDiResponseDocument::new("{not-json}")?; + assert_eq!(coarse_only.as_str(), "{not-json}"); + Ok(()) +} + +#[test] +fn response_document_budget_accepts_exact_limit_and_rejects_one_more_byte() +-> Result<(), Box> { + const OBJECT_OVERHEAD_BYTES: usize = 8; + let exact = format!( + "{{\"x\":\"{}\"}}", + "a".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES - OBJECT_OVERHEAD_BYTES) + ); + assert_eq!(exact.len(), MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES); + assert!(BoundedWebDriverBiDiResponseDocument::new(&exact).is_ok()); + + let oversized = format!("{exact} "); + assert_eq!( + oversized.len(), + MAX_WEBDRIVER_BIDI_RESPONSE_DOCUMENT_BYTES + 1 + ); + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(&oversized), + Err(WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge) + ); + Ok(()) +} + +#[test] +fn response_document_errors_are_deterministic_and_source_free() { + for error in [ + WebDriverBiDiResponseDocumentAdmissionError::EmptyDocument, + WebDriverBiDiResponseDocumentAdmissionError::DocumentTooLarge, + WebDriverBiDiResponseDocumentAdmissionError::InvalidUtf8, + WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary, + ] { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs b/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs new file mode 100644 index 000000000..692e7ea68 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_envelope_failure_edges.rs @@ -0,0 +1,72 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH, + WebDriverBiDiResponseDocumentAdmissionError, WebDriverBiDiResponseEnvelopeParseError, +}; + +fn assert_invalid_json(raw: &str) -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson) + ); + Ok(()) +} + +#[test] +fn non_object_response_stops_at_document_admission_before_parser() { + assert!(matches!( + BoundedWebDriverBiDiResponseDocument::new("[]"), + Err(WebDriverBiDiResponseDocumentAdmissionError::InvalidObjectBoundary) + )); +} + +#[test] +fn parser_rejects_truncated_literal_documents() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":1,"result":{},"x":falsX}"#, + r#"{"type":"success","id":1,"result":{},"x":nulX}"#, + ] { + assert_invalid_json(raw)?; + } + Ok(()) +} + +#[test] +fn parser_rejects_malformed_escape_and_unicode_code_units() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":1,"result":{},"x":"\q"}"#, + r#"{"type":"success","id":1,"result":{},"x":"\u12G4"}"#, + r#"{"type":"success","id":1,"result":{},"x":"\uD83D\u12G4"}"#, + ] { + assert_invalid_json(raw)?; + } + Ok(()) +} + +#[test] +fn parser_rejects_nested_object_key_and_colon_faults() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":1,"result":{},"x":{1:2}}"#, + r#"{"type":"success","id":1,"result":{},"x":{"a" 1}}"#, + ] { + assert_invalid_json(raw)?; + } + Ok(()) +} + +#[test] +fn parser_enforces_depth_budget_for_object_nesting() -> Result<(), Box> { + let over_nested = format!( + "{{\"type\":\"success\",\"id\":1,\"result\":{{\"x\":{}{}}}}}", + "{\"k\":".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 1), + "}".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 1) + ); + let document = BoundedWebDriverBiDiResponseDocument::new(&over_nested)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::JsonDepthExceeded) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_envelope_hostile_json.rs b/crates/originweave-core/tests/webdriver_bidi_response_envelope_hostile_json.rs new file mode 100644 index 000000000..5b56399be --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_envelope_hostile_json.rs @@ -0,0 +1,107 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, WebDriverBiDiResponseEnvelopeParseError, +}; + +fn assert_invalid_json(raw: &str) -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson) + ); + Ok(()) +} + +#[test] +fn parser_rejects_top_level_separator_trailing_document_and_missing_colon_faults() +-> Result<(), Box> { + for raw in [ + "{\"type\":\"success\" \"id\":1,\"result\":{}}", + "{\"type\" \"success\",\"id\":1,\"result\":{}}", + "{\"type\":\"success\",\"id\":1,\"result\":{}} {}", + ] { + assert_invalid_json(raw)?; + } + + let empty = BoundedWebDriverBiDiResponseDocument::new("{}")?; + assert_eq!( + empty.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::MissingResponseType) + ); + Ok(()) +} + +#[test] +fn parser_rejects_malformed_nested_object_array_string_and_literal_values() +-> Result<(), Box> { + for raw in [ + "{\"type\":\"success\",\"id\":1,\"result\":{\"a\":1 \"b\":2}}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":[1 2]}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":tru}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":-}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":1.}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":1e}", + "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":\"unterminated}", + ] { + assert_invalid_json(raw)?; + } + + let raw_control = "{\"type\":\"success\",\"id\":1,\"result\":{},\"x\":\"bad\u{0001}text\"}"; + assert_invalid_json(raw_control)?; + Ok(()) +} + +#[test] +fn parser_accepts_complete_json_escape_number_and_nested_container_forms() +-> Result<(), Box> { + let raw = concat!( + r#"{"type":"success","id":1,"result":{"a":1,"b":2},"esc":""#, + r#"\"\\\/\b\f\n\r\t","zero":0,"signed_exponent":1e+2,"nested":[1,2,{"ok":true}]}"#, + ); + let parsed = BoundedWebDriverBiDiResponseDocument::new(raw)?.parse_command_response()?; + assert_eq!(parsed.response_id(), Some(1)); + Ok(()) +} + +#[test] +fn parser_accepts_all_utf8_widths_from_json_unicode_escapes() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":1,"result":{},"text":"\u0041"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u00E9"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u263A"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\uD83D\uDE00"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u00AF"}"#, + ] { + let parsed = BoundedWebDriverBiDiResponseDocument::new(raw)?.parse_command_response()?; + assert_eq!(parsed.response_id(), Some(1)); + } + Ok(()) +} + +#[test] +fn parser_rejects_invalid_unicode_escape_sequences() -> Result<(), Box> { + for raw in [ + r#"{"type":"success","id":1,"result":{},"text":"\uD83D"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\uD83D\x"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\uD83D\u0041"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\uDE00"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u12"}"#, + r#"{"type":"success","id":1,"result":{},"text":"\u00G0"}"#, + ] { + assert_invalid_json(raw)?; + } + Ok(()) +} + +#[test] +fn parser_rejects_integer_overflow_even_before_protocol_range_validation() +-> Result<(), Box> { + let raw = "{\"type\":\"success\",\"id\":18446744073709551616,\"result\":{}}"; + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs b/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs new file mode 100644 index 000000000..d15a525b0 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_envelope_parser.rs @@ -0,0 +1,254 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, MAX_WEBDRIVER_BIDI_COMMAND_ID, + MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH, MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS, + ParsedWebDriverBiDiCommandResponseEnvelope, WebDriverBiDiCommandResponseKind, + WebDriverBiDiResponseEnvelopeParseError, +}; + +#[test] +fn parser_classifies_exact_success_and_nullable_error_envelopes() -> Result<(), Box> { + let success_raw = " {\"type\":\"success\",\"id\":42,\"result\":{\"nodes\":[]}}\r\n"; + let success = + BoundedWebDriverBiDiResponseDocument::new(success_raw)?.parse_command_response()?; + assert_eq!(success.kind(), WebDriverBiDiCommandResponseKind::Success); + assert_eq!(success.response_id(), Some(42)); + assert_eq!(success.as_str(), success_raw); + + let error_raw = "{\"type\":\"error\",\"id\":null,\"error\":\"invalid argument\",\"message\":\"bad request\"}"; + let error = BoundedWebDriverBiDiResponseDocument::new(error_raw)?.parse_command_response()?; + assert_eq!(error.kind(), WebDriverBiDiCommandResponseKind::Error); + assert_eq!(error.response_id(), None); + assert_eq!(error.as_str(), error_raw); + Ok(()) +} + +#[test] +fn parser_accepts_extensible_fields_only_when_the_complete_json_is_valid() +-> Result<(), Box> { + let raw = concat!( + "{\"vendor\":{\"nested\":[true,false,null,{\"text\":\"a\\\\b\\\"c\\u263a\"}]},", + "\"id\":7,\"result\":{},\"type\":\"success\"}" + ); + let parsed = BoundedWebDriverBiDiResponseDocument::new(raw)?.parse_command_response()?; + assert_eq!(parsed.response_id(), Some(7)); + + for malformed in [ + "{\"type\":\"success\",\"id\":7,\"result\":{},}", + "{\"type\":\"success\",\"id\":7,\"result\":{},\"x\":01}", + "{\"type\":\"success\",\"id\":7,\"result\":{},\"x\":\"\\q\"}", + "{\"type\":\"success\",\"id\":7,\"result\":{},\"x\":[1,]}", + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(malformed)?; + assert_eq!( + document.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::InvalidJson) + ); + } + Ok(()) +} + +#[test] +fn parser_rejects_missing_duplicate_or_unexpected_response_discriminators() +-> Result<(), Box> { + for (raw, expected) in [ + ( + "{\"id\":1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::MissingResponseType, + ), + ( + "{\"type\":\"event\",\"id\":1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::UnexpectedResponseType, + ), + ( + "{\"type\":\"success\",\"type\":\"error\",\"id\":1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField, + ), + ( + "{\"type\":\"success\",\"id\":1,\"id\":1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField, + ), + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!(document.parse_command_response(), Err(expected)); + } + Ok(()) +} + +#[test] +fn parser_requires_a_present_protocol_range_id_and_success_result() -> Result<(), Box> { + for (raw, expected) in [ + ( + "{\"type\":\"success\",\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::MissingResponseId, + ), + ( + "{\"type\":\"error\",\"error\":\"invalid argument\",\"message\":\"bad\"}", + WebDriverBiDiResponseEnvelopeParseError::MissingResponseId, + ), + ( + "{\"type\":\"success\",\"id\":null,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":-1,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":1.0,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":1e0,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":9007199254740992,\"result\":{}}", + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + ), + ( + "{\"type\":\"success\",\"id\":1}", + WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload, + ), + ( + "{\"type\":\"success\",\"id\":1,\"result\":[]}", + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ), + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!(document.parse_command_response(), Err(expected)); + } + + let maximum = + format!("{{\"type\":\"success\",\"id\":{MAX_WEBDRIVER_BIDI_COMMAND_ID},\"result\":{{}}}}"); + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(&maximum)? + .parse_command_response()? + .response_id(), + Some(MAX_WEBDRIVER_BIDI_COMMAND_ID) + ); + Ok(()) +} + +#[test] +fn parser_requires_error_code_message_and_string_stacktrace() -> Result<(), Box> { + for (raw, expected) in [ + ( + "{\"type\":\"error\",\"id\":1,\"message\":\"bad\"}", + WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload, + ), + ( + "{\"type\":\"error\",\"id\":1,\"error\":\"invalid argument\"}", + WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload, + ), + ( + "{\"type\":\"error\",\"id\":1,\"error\":1,\"message\":\"bad\"}", + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ), + ( + "{\"type\":\"error\",\"id\":1,\"error\":\"invalid argument\",\"message\":false}", + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ), + ( + "{\"type\":\"error\",\"id\":1,\"error\":\"invalid argument\",\"message\":\"bad\",\"stacktrace\":[]}", + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ), + ] { + let document = BoundedWebDriverBiDiResponseDocument::new(raw)?; + assert_eq!(document.parse_command_response(), Err(expected)); + } + + let valid = BoundedWebDriverBiDiResponseDocument::new( + "{\"type\":\"error\",\"id\":7,\"error\":\"invalid argument\",\"message\":\"bad\",\"stacktrace\":\"frame\"}", + )? + .parse_command_response()?; + assert_eq!(valid.response_id(), Some(7)); + Ok(()) +} + +#[test] +fn parser_enforces_top_level_field_and_json_depth_budgets() -> Result<(), Box> { + let mut fields = vec![ + "\"type\":\"success\"".to_owned(), + "\"id\":1".to_owned(), + "\"result\":{}".to_owned(), + ]; + while fields.len() < MAX_WEBDRIVER_BIDI_RESPONSE_TOP_LEVEL_FIELDS { + fields.push(format!("\"x{}\":null", fields.len())); + } + let exact_fields = format!("{{{}}}", fields.join(",")); + assert!( + BoundedWebDriverBiDiResponseDocument::new(&exact_fields)? + .parse_command_response() + .is_ok() + ); + fields.push("\"overflow\":null".to_owned()); + let over_fields = format!("{{{}}}", fields.join(",")); + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(&over_fields)?.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::TopLevelFieldCountExceeded) + ); + + let exact_nested = format!( + "{{\"type\":\"success\",\"id\":1,\"result\":{{\"x\":{}{}}}}}", + "[".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 2), + "]".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 2) + ); + assert!( + BoundedWebDriverBiDiResponseDocument::new(&exact_nested)? + .parse_command_response() + .is_ok() + ); + + let over_nested = format!( + "{{\"type\":\"success\",\"id\":1,\"result\":{{\"x\":{}{}}}}}", + "[".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 1), + "]".repeat(MAX_WEBDRIVER_BIDI_RESPONSE_JSON_DEPTH - 1) + ); + assert_eq!( + BoundedWebDriverBiDiResponseDocument::new(&over_nested)?.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::JsonDepthExceeded) + ); + Ok(()) +} + +#[test] +fn parser_normalizes_escaped_top_level_names_before_duplicate_detection() +-> Result<(), Box> { + let duplicate = BoundedWebDriverBiDiResponseDocument::new( + "{\"type\":\"success\",\"\\u0069d\":1,\"id\":1,\"result\":{}}", + )?; + assert_eq!( + duplicate.parse_command_response(), + Err(WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField) + ); + + let unicode_extension = BoundedWebDriverBiDiResponseDocument::new( + "{\"type\":\"success\",\"id\":1,\"result\":{},\"메타\":\"값\"}", + )? + .parse_command_response()?; + assert_eq!(unicode_extension.response_id(), Some(1)); + Ok(()) +} + +#[test] +fn response_envelope_parse_errors_are_deterministic_and_source_free() { + for error in [ + WebDriverBiDiResponseEnvelopeParseError::InvalidJson, + WebDriverBiDiResponseEnvelopeParseError::JsonDepthExceeded, + WebDriverBiDiResponseEnvelopeParseError::TopLevelFieldCountExceeded, + WebDriverBiDiResponseEnvelopeParseError::DuplicateTopLevelField, + WebDriverBiDiResponseEnvelopeParseError::MissingResponseType, + WebDriverBiDiResponseEnvelopeParseError::UnexpectedResponseType, + WebDriverBiDiResponseEnvelopeParseError::MissingResponseId, + WebDriverBiDiResponseEnvelopeParseError::InvalidResponseId, + WebDriverBiDiResponseEnvelopeParseError::MissingRequiredPayload, + WebDriverBiDiResponseEnvelopeParseError::InvalidRequiredPayloadType, + ] { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +} + +fn _parsed_type_is_public(_parsed: ParsedWebDriverBiDiCommandResponseEnvelope) {} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs new file mode 100644 index 000000000..92b5c6611 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_error_code.rs @@ -0,0 +1,130 @@ +use std::error::Error; + +use originweave_core::{BoundedWebDriverBiDiResponseDocument, WebDriverBiDiErrorCode}; + +const CURRENT_WEBDRIVER_BIDI_ERROR_CODES: &[(&str, WebDriverBiDiErrorCode)] = &[ + ("invalid argument", WebDriverBiDiErrorCode::InvalidArgument), + ("invalid selector", WebDriverBiDiErrorCode::InvalidSelector), + ( + "invalid session id", + WebDriverBiDiErrorCode::InvalidSessionId, + ), + ( + "invalid web extension", + WebDriverBiDiErrorCode::InvalidWebExtension, + ), + ( + "move target out of bounds", + WebDriverBiDiErrorCode::MoveTargetOutOfBounds, + ), + ("no such alert", WebDriverBiDiErrorCode::NoSuchAlert), + ( + "no such client window", + WebDriverBiDiErrorCode::NoSuchClientWindow, + ), + ( + "no such network collector", + WebDriverBiDiErrorCode::NoSuchNetworkCollector, + ), + ("no such element", WebDriverBiDiErrorCode::NoSuchElement), + ("no such frame", WebDriverBiDiErrorCode::NoSuchFrame), + ("no such handle", WebDriverBiDiErrorCode::NoSuchHandle), + ( + "no such history entry", + WebDriverBiDiErrorCode::NoSuchHistoryEntry, + ), + ("no such intercept", WebDriverBiDiErrorCode::NoSuchIntercept), + ( + "no such network data", + WebDriverBiDiErrorCode::NoSuchNetworkData, + ), + ("no such node", WebDriverBiDiErrorCode::NoSuchNode), + ("no such request", WebDriverBiDiErrorCode::NoSuchRequest), + ( + "no such screencast", + WebDriverBiDiErrorCode::NoSuchScreencast, + ), + ("no such script", WebDriverBiDiErrorCode::NoSuchScript), + ( + "no such storage partition", + WebDriverBiDiErrorCode::NoSuchStoragePartition, + ), + ( + "no such user context", + WebDriverBiDiErrorCode::NoSuchUserContext, + ), + ( + "no such web extension", + WebDriverBiDiErrorCode::NoSuchWebExtension, + ), + ( + "session not created", + WebDriverBiDiErrorCode::SessionNotCreated, + ), + ( + "unable to capture screen", + WebDriverBiDiErrorCode::UnableToCaptureScreen, + ), + ( + "unable to close browser", + WebDriverBiDiErrorCode::UnableToCloseBrowser, + ), + ( + "unable to set cookie", + WebDriverBiDiErrorCode::UnableToSetCookie, + ), + ( + "unable to set file input", + WebDriverBiDiErrorCode::UnableToSetFileInput, + ), + ( + "unavailable network data", + WebDriverBiDiErrorCode::UnavailableNetworkData, + ), + ( + "underspecified storage partition", + WebDriverBiDiErrorCode::UnderspecifiedStoragePartition, + ), + ("unknown command", WebDriverBiDiErrorCode::UnknownCommand), + ("unknown error", WebDriverBiDiErrorCode::UnknownError), + ( + "unsupported operation", + WebDriverBiDiErrorCode::UnsupportedOperation, + ), +]; + +#[test] +fn parser_retains_every_current_webdriver_bidi_error_code() -> Result<(), Box> { + for &(raw_code, expected) in CURRENT_WEBDRIVER_BIDI_ERROR_CODES { + let raw = format!( + "{{\"type\":\"error\",\"id\":7,\"error\":\"{raw_code}\",\"message\":\"browser rejected command\"}}" + ); + let parsed = BoundedWebDriverBiDiResponseDocument::new(&raw)?.parse_command_response()?; + assert_eq!( + parsed.error_code(), + Some(expected), + "current WebDriver BiDi error code must retain its typed mapping: {raw_code}" + ); + } + Ok(()) +} + +#[test] +fn parser_rejects_unknown_webdriver_bidi_error_code() -> Result<(), Box> { + let document = BoundedWebDriverBiDiResponseDocument::new( + "{\"type\":\"error\",\"id\":7,\"error\":\"made up browser failure\",\"message\":\"untrusted adapter text\"}", + )?; + + let error = match document.parse_command_response() { + Ok(_) => { + return Err(std::io::Error::other( + "unknown WebDriver BiDi error code was unexpectedly accepted", + ) + .into()); + } + Err(error) => error, + }; + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs b/crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs new file mode 100644 index 000000000..88bed1649 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_response_error_code_evidence.rs @@ -0,0 +1,35 @@ +use std::error::Error; + +use originweave_core::{BoundedWebDriverBiDiResponseDocument, WebDriverBiDiErrorCode}; + +#[test] +fn parsed_error_envelope_retains_typed_error_code() -> Result<(), Box> { + for (raw_code, expected) in [ + ("invalid argument", WebDriverBiDiErrorCode::InvalidArgument), + ( + "no such client window", + WebDriverBiDiErrorCode::NoSuchClientWindow, + ), + ( + "unavailable network data", + WebDriverBiDiErrorCode::UnavailableNetworkData, + ), + ] { + let raw = format!( + "{{\"type\":\"error\",\"id\":7,\"error\":\"{raw_code}\",\"message\":\"remote failure\"}}" + ); + let parsed = BoundedWebDriverBiDiResponseDocument::new(&raw)?.parse_command_response()?; + assert_eq!(parsed.error_code(), Some(expected)); + } + Ok(()) +} + +#[test] +fn parsed_success_envelope_has_no_error_code() -> Result<(), Box> { + let parsed = + BoundedWebDriverBiDiResponseDocument::new("{\"type\":\"success\",\"id\":7,\"result\":{}}")? + .parse_command_response()?; + + assert_eq!(parsed.error_code(), None); + Ok(()) +} diff --git a/crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs b/crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs new file mode 100644 index 000000000..dd20a4ef1 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_socket_peer_verification.rs @@ -0,0 +1,107 @@ +use std::{error::Error, net::SocketAddr}; + +use originweave_core::{ + CorrelatedWebDriverBiDiWebSocketEndpoint, WebDriverBiDiSocketPeerVerificationError, + WebDriverBiDiWebSocketEndpoint, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn connect_target(endpoint: &str) -> originweave_core::WebDriverBiDiWebSocketConnectTarget { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + + let correlated: Result = + admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted literal loopback target") + }; + target +} + +#[test] +fn exact_connected_peer_becomes_verified_transport_metadata() { + let endpoint = format!("wss://127.0.0.1:9443/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let peer = SocketAddr::from(([127, 0, 0, 1], 9443)); + + let verified = target.verify_connected_peer(peer); + assert!(verified.is_ok(), "{verified:?}"); + let Ok(verified) = verified else { + return; + }; + + assert_eq!(verified.socket_addr(), peer); + assert!(verified.requires_tls()); + assert_eq!(verified.session_id(), SESSION_ID); +} + +#[test] +fn connected_peer_with_wrong_port_fails_closed() { + let endpoint = format!("ws://127.0.0.1:9515/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let actual = SocketAddr::from(([127, 0, 0, 1], 9516)); + + let result = target.verify_connected_peer(actual); + assert_eq!( + result, + Err(WebDriverBiDiSocketPeerVerificationError::PeerMismatch { + expected: SocketAddr::from(([127, 0, 0, 1], 9515)), + actual, + }) + ); +} + +#[test] +fn connected_peer_with_different_address_fails_closed() { + let endpoint = format!("ws://[::1]:9515/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let actual = SocketAddr::from(([127, 0, 0, 1], 9515)); + + let result = target.verify_connected_peer(actual); + assert!(matches!( + result, + Err(WebDriverBiDiSocketPeerVerificationError::PeerMismatch { .. }) + )); +} + +#[test] +fn non_loopback_observed_peer_cannot_inherit_approved_loopback_authority() { + let endpoint = format!("ws://127.0.0.1:9515/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let actual = SocketAddr::from(([192, 0, 2, 10], 9515)); + + let result = target.verify_connected_peer(actual); + assert!(matches!( + result, + Err(WebDriverBiDiSocketPeerVerificationError::PeerMismatch { .. }) + )); +} + +#[test] +fn peer_mismatch_error_is_deterministic_and_source_free() { + let endpoint = format!("ws://127.0.0.1:9515/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let actual = SocketAddr::from(([127, 0, 0, 1], 9516)); + + let result = target.verify_connected_peer(actual); + let Err(error) = result else { + return; + }; + + assert_eq!( + error.to_string(), + "connected WebDriver BiDi socket peer does not match the approved destination" + ); + assert!(error.source().is_none()); +} diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs new file mode 100644 index 000000000..85f26f658 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_connect_target.rs @@ -0,0 +1,99 @@ +use std::{error::Error, net::SocketAddr}; + +use originweave_core::{ + CorrelatedWebDriverBiDiWebSocketEndpoint, WebDriverBiDiWebSocketConnectTargetError, + WebDriverBiDiWebSocketEndpoint, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn correlated(endpoint: &str) -> CorrelatedWebDriverBiDiWebSocketEndpoint { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + correlated +} + +#[test] +fn explicit_ipv4_loopback_becomes_exact_no_dns_connect_target() { + let endpoint = format!("ws://127.0.0.1:9515/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); + assert!(result.is_ok(), "{result:?}"); + let Ok(target) = result else { + return; + }; + + assert_eq!( + target.socket_addr(), + SocketAddr::from(([127, 0, 0, 1], 9515)) + ); + assert!(!target.requires_tls()); + assert_eq!(target.session_id(), SESSION_ID); +} + +#[test] +fn explicit_ipv6_loopback_preserves_exact_destination_and_tls_requirement() { + let endpoint = format!("wss://[::1]:9443/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); + assert!(result.is_ok(), "{result:?}"); + let Ok(target) = result else { + return; + }; + + assert_eq!( + target.socket_addr(), + SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 9443)) + ); + assert!(target.requires_tls()); + assert_eq!(target.session_id(), SESSION_ID); +} + +#[test] +fn localhost_name_never_silently_inherits_ambient_dns_authority() { + let endpoint = format!("ws://localhost:9515/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); + assert!(matches!( + &result, + Err(WebDriverBiDiWebSocketConnectTargetError::NameResolutionRequired { .. }) + )); +} + +#[test] +fn name_resolution_failure_preserves_correlated_endpoint_for_trusted_resolver() { + let endpoint = format!("ws://localhost:9515/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); + let Err(error) = result else { + return; + }; + + assert_eq!(error.correlated_endpoint().as_str(), endpoint); + assert_eq!(error.correlated_endpoint().session_id(), SESSION_ID); + assert!(!error.correlated_endpoint().is_secure()); + assert_eq!(error.correlated_endpoint().port(), 9515); + + let recovered = error.into_correlated_endpoint(); + assert_eq!(recovered.as_str(), endpoint); + assert_eq!(recovered.session_id(), SESSION_ID); +} + +#[test] +fn connect_target_errors_are_deterministic_and_source_free() { + let endpoint = format!("ws://localhost:9515/session/{SESSION_ID}"); + let result = correlated(&endpoint).into_explicit_connect_target(); + let Err(error) = result else { + return; + }; + + assert_eq!( + error.to_string(), + "WebDriver BiDi WebSocket endpoint requires explicit trusted name resolution" + ); + assert!(error.source().is_none()); +} diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs new file mode 100644 index 000000000..9440dff76 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_endpoint.rs @@ -0,0 +1,206 @@ +use std::error::Error; + +use originweave_core::{ + MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES, WebDriverBiDiWebSocketEndpoint, + WebDriverBiDiWebSocketEndpointAdmissionError, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const CHROMEDRIVER_SESSION_ID: &str = "0123456789abcdef0123456789abcdef"; + +#[test] +fn canonical_loopback_session_endpoints_are_admitted_without_granting_authority() { + let ipv4_result = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://127.0.0.1:9515/session/{SESSION_ID}")); + assert!(ipv4_result.is_ok(), "{ipv4_result:?}"); + let Ok(ipv4) = ipv4_result else { + return; + }; + assert!(!ipv4.is_secure()); + assert_eq!(ipv4.host(), "127.0.0.1"); + assert_eq!(ipv4.port(), 9515); + assert_eq!(ipv4.session_id(), SESSION_ID); + assert_eq!( + ipv4.as_str(), + format!("ws://127.0.0.1:9515/session/{SESSION_ID}") + ); + + let localhost_result = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://localhost:4444/session/{SESSION_ID}")); + assert!(localhost_result.is_ok(), "{localhost_result:?}"); + let Ok(localhost) = localhost_result else { + return; + }; + assert_eq!(localhost.host(), "localhost"); + + let ipv6_result = + WebDriverBiDiWebSocketEndpoint::new(&format!("wss://[::1]:9222/session/{SESSION_ID}")); + assert!(ipv6_result.is_ok(), "{ipv6_result:?}"); + let Ok(ipv6) = ipv6_result else { + return; + }; + assert!(ipv6.is_secure()); + assert_eq!(ipv6.host(), "::1"); + assert_eq!(ipv6.port(), 9222); +} + +#[test] +fn chromedriver_generated_session_identifier_is_admitted_for_real_chromium_fixture() { + let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://127.0.0.1:9515/session/{CHROMEDRIVER_SESSION_ID}" + )); + assert!(endpoint.is_ok(), "{endpoint:?}"); + let Ok(endpoint) = endpoint else { + return; + }; + assert_eq!(endpoint.session_id(), CHROMEDRIVER_SESSION_ID); +} + +#[test] +fn remote_or_ambiguous_authorities_fail_closed() { + for endpoint in [ + format!("ws://example.com:9515/session/{SESSION_ID}"), + format!("ws://192.0.2.1:9515/session/{SESSION_ID}"), + format!("ws://[2001:db8::1]:9515/session/{SESSION_ID}"), + ] { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost) + )); + } + + for endpoint in [ + format!("ws:///session/{SESSION_ID}"), + format!("ws://user@localhost:9515/session/{SESSION_ID}"), + format!("ws://localhost/session/{SESSION_ID}"), + format!("ws://::1:9515/session/{SESSION_ID}"), + format!("ws://[::1]9515/session/{SESSION_ID}"), + format!("ws://[::zz]:9515/session/{SESSION_ID}"), + format!("ws://:9515/session/{SESSION_ID}"), + ] { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority) + )); + } +} + +#[test] +fn malformed_loopback_authority_edge_cases_fail_closed() { + for endpoint in [ + format!("ws://[::1:9515/session/{SESSION_ID}"), + format!("ws://[::1]:/session/{SESSION_ID}"), + format!("ws://[0:0:0:0:0:0:0:1]:9515/session/{SESSION_ID}"), + format!("ws://localhost:/session/{SESSION_ID}"), + format!("ws://local_host:9515/session/{SESSION_ID}"), + ] { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority) + )); + } +} + +#[test] +fn port_and_session_resource_are_canonical_and_bounded() { + for endpoint in [ + format!("ws://localhost:0/session/{SESSION_ID}"), + format!("ws://localhost:09515/session/{SESSION_ID}"), + format!("ws://localhost:65536/session/{SESSION_ID}"), + format!("ws://localhost:+9515/session/{SESSION_ID}"), + ] { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidPort) + )); + } + + for endpoint in [ + format!("ws://localhost:9515/other/{SESSION_ID}"), + format!("ws://localhost:9515/session/{SESSION_ID}/extra"), + "ws://localhost:9515/session/".to_owned(), + "ws://localhost:9515".to_owned(), + ] { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&endpoint), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource) + )); + } + + for session_id in [ + "01234567-89ab-cdef-0123-456789abcdeF", + "0123456789ab-cdef-0123-456789abcdef", + "01234567-89ab-cdef-0123-456789abcdeg", + "01234567_89ab-cdef-0123-456789abcdef", + "0123456789abcdef0123456789abcdeF", + "0123456789abcdef0123456789abcdeg", + "0123456789abcdef0123456789abcde_", + "0123456789abcdef0123456789abcde", + ] { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://localhost:9515/session/{session_id}" + )), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionId) + )); + } +} + +#[test] +fn endpoint_text_rejects_noncanonical_or_unbounded_inputs_before_transport_use() { + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(""), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::EmptyEndpoint) + )); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&format!("http://localhost:9515/session/{SESSION_ID}")), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidScheme) + )); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://local host:9515/session/{SESSION_ID}")), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText) + )); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://locálhost:9515/session/{SESSION_ID}")), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText) + )); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://localhost:9515/session/{SESSION_ID}?token=secret" + )), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden) + )); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://localhost:9515/session/{SESSION_ID}#fragment" + )), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden) + )); + + let oversized = "x".repeat(MAX_WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_BYTES + 1); + assert!(matches!( + WebDriverBiDiWebSocketEndpoint::new(&oversized), + Err(WebDriverBiDiWebSocketEndpointAdmissionError::EndpointTooLong) + )); +} + +#[test] +fn endpoint_error_contract_is_deterministic_and_source_free() { + let errors = [ + WebDriverBiDiWebSocketEndpointAdmissionError::EmptyEndpoint, + WebDriverBiDiWebSocketEndpointAdmissionError::EndpointTooLong, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidEndpointText, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidScheme, + WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidAuthority, + WebDriverBiDiWebSocketEndpointAdmissionError::NonLoopbackHost, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidPort, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionResource, + WebDriverBiDiWebSocketEndpointAdmissionError::InvalidSessionId, + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs b/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs new file mode 100644 index 000000000..e522d7c55 --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_websocket_session_correlation.rs @@ -0,0 +1,92 @@ +use std::error::Error; + +use originweave_core::{ + WebDriverBiDiWebSocketEndpoint, WebDriverBiDiWebSocketEndpointCorrelationError, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const OTHER_SESSION_ID: &str = "11234567-89ab-cdef-0123-456789abcdef"; +const CHROMEDRIVER_SESSION_ID: &str = "0123456789abcdef0123456789abcdef"; + +fn endpoint() -> WebDriverBiDiWebSocketEndpoint { + let result = + WebDriverBiDiWebSocketEndpoint::new(&format!("ws://127.0.0.1:9515/session/{SESSION_ID}")); + assert!(result.is_ok(), "{result:?}"); + let Ok(endpoint) = result else { + unreachable!("asserted valid endpoint") + }; + endpoint +} + +#[test] +fn exact_session_identity_correlation_preserves_bounded_endpoint_metadata() { + let result = endpoint().correlate_session_id(SESSION_ID); + assert!(result.is_ok(), "{result:?}"); + let Ok(correlated) = result else { + return; + }; + + assert_eq!( + correlated.as_str(), + format!("ws://127.0.0.1:9515/session/{SESSION_ID}") + ); + assert!(!correlated.is_secure()); + assert_eq!(correlated.host(), "127.0.0.1"); + assert_eq!(correlated.port(), 9515); + assert_eq!(correlated.session_id(), SESSION_ID); +} + +#[test] +fn chromedriver_session_identity_correlation_preserves_exact_session_evidence() { + let endpoint = WebDriverBiDiWebSocketEndpoint::new(&format!( + "ws://127.0.0.1:9515/session/{CHROMEDRIVER_SESSION_ID}" + )); + assert!(endpoint.is_ok(), "{endpoint:?}"); + let Ok(endpoint) = endpoint else { + return; + }; + + let result = endpoint.correlate_session_id(CHROMEDRIVER_SESSION_ID); + assert!(result.is_ok(), "{result:?}"); + let Ok(correlated) = result else { + return; + }; + assert_eq!(correlated.session_id(), CHROMEDRIVER_SESSION_ID); +} + +#[test] +fn a_different_canonical_session_identity_fails_closed() { + assert!(matches!( + endpoint().correlate_session_id(OTHER_SESSION_ID), + Err(WebDriverBiDiWebSocketEndpointCorrelationError::SessionIdMismatch) + )); +} + +#[test] +fn malformed_expected_session_identity_is_rejected_before_comparison() { + for expected in [ + "", + "01234567-89ab-cdef-0123-456789abcdeF", + "0123456789ab-cdef-0123-456789abcdef", + "01234567-89ab-cdef-0123-456789abcdeg", + "01234567_89ab-cdef-0123-456789abcdef", + "0123456789abcdef0123456789abcdeF", + "0123456789abcdef0123456789abcdeg", + ] { + assert!(matches!( + endpoint().correlate_session_id(expected), + Err(WebDriverBiDiWebSocketEndpointCorrelationError::InvalidExpectedSessionId) + )); + } +} + +#[test] +fn session_correlation_errors_are_deterministic_and_source_free() { + for error in [ + WebDriverBiDiWebSocketEndpointCorrelationError::InvalidExpectedSessionId, + WebDriverBiDiWebSocketEndpointCorrelationError::SessionIdMismatch, + ] { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs b/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs new file mode 100644 index 000000000..70802e89c --- /dev/null +++ b/crates/originweave-core/tests/webdriver_bidi_wire_authority_binding.rs @@ -0,0 +1,156 @@ +use std::error::Error; + +use originweave_core::{ + BoundedWebDriverBiDiResponseDocument, BrowserAuthorityRegistry, BrowserContextDispatchTarget, + BrowserContextOriginDispatchTarget, BrowserContextOriginEpochDispatchTarget, + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + BrowserRegistryError, Origin, OriginWeaveProtocolVersion, ValidatedBrowserProtocolUse, + WebDriverBiDiAccessibilityQuery, WebDriverBiDiLocateNodesAdmissionError, + WebDriverBiDiLocateNodesCommand, WebDriverBiDiLocateNodesResponseCorrelationError, + WebDriverBiDiLocateNodesResponseDocumentError, WebDriverBiDiLocateNodesResponseEnvelopeError, +}; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +fn locate_nodes_command() -> Result> { + let query = WebDriverBiDiAccessibilityQuery::new(Some("button"), Some("Submit task"), 1)?; + Ok(WebDriverBiDiLocateNodesCommand::new( + 42, + "context-a", + &query, + )?) +} + +fn controlled_origin() -> Result> { + Origin::parse("https://app.example").map_err(|_error| "valid controlled fixture origin".into()) +} + +fn current_target<'a>( + registry: &mut BrowserAuthorityRegistry, + origin: &'a Origin, + external_context: &str, +) -> Result, Box> { + let session = registry.register_session("webdriver-session")?; + let context = registry.register_context(session, external_context)?; + let epoch = registry.bind_context_origin(session, context, origin)?; + Ok(BrowserContextOriginEpochDispatchTarget::new( + BrowserContextOriginDispatchTarget::new( + BrowserContextDispatchTarget::new(session, context), + origin, + ), + epoch, + )) +} + +fn semantic_observation_proof() -> Result> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + Ok(descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?) +} + +fn successful_wire_document() -> Result> { + Ok(BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":42,"result":{"nodes":[{"type":"node","sharedId":"node-a"}]}}"#, + )?) +} + +fn mismatched_wire_document() -> Result> { + Ok(BoundedWebDriverBiDiResponseDocument::new( + r#"{"type":"success","id":43,"result":{"nodes":[{"type":"node","sharedId":"node-a"}]}}"#, + )?) +} + +#[test] +fn wire_response_binds_nodes_to_exact_current_authority_without_caller_selected_intermediate_result() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + + let handles = locate_nodes_command()?.bind_response_document_nodes( + successful_wire_document()?, + semantic_observation_proof()?, + &mut registry, + target, + )?; + + assert_eq!(handles.len(), 1); + assert_eq!(handles[0].origin(), &origin); + assert_eq!(handles[0].document_epoch(), target.expected_epoch()); + Ok(()) +} + +#[test] +fn wire_response_binding_preserves_wire_correlation_failure_before_authority() +-> Result<(), Box> { + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-a")?; + + let error = locate_nodes_command()?.bind_response_document_nodes( + mismatched_wire_document()?, + semantic_observation_proof()?, + &mut registry, + target, + ); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseDocumentError::Envelope( + WebDriverBiDiLocateNodesResponseEnvelopeError::Correlation( + WebDriverBiDiLocateNodesResponseCorrelationError::ResponseIdMismatch { + expected: 42, + actual: 43, + }, + ), + )) + ); + Ok(()) +} + +#[test] +fn wire_response_binding_preserves_current_context_authority_failure() -> Result<(), Box> +{ + let mut registry = BrowserAuthorityRegistry::new(); + let origin = controlled_origin()?; + let target = current_target(&mut registry, &origin, "context-b")?; + + let error = locate_nodes_command()?.bind_response_document_nodes( + successful_wire_document()?, + semantic_observation_proof()?, + &mut registry, + target, + ); + + assert_eq!( + error, + Err(WebDriverBiDiLocateNodesResponseDocumentError::NodeBinding( + WebDriverBiDiLocateNodesAdmissionError::BrowserAuthority( + BrowserRegistryError::ContextExternalIdentifierMismatch, + ), + )) + ); + let error = error + .err() + .ok_or("expected exact current context failure")?; + assert!(error.source().is_some()); + assert!(!error.to_string().is_empty()); + Ok(()) +} diff --git a/crates/originweave-destination/src/lib.rs b/crates/originweave-destination/src/lib.rs index 774ba9ee9..5fdf2d363 100644 --- a/crates/originweave-destination/src/lib.rs +++ b/crates/originweave-destination/src/lib.rs @@ -24,7 +24,6 @@ pub use redirect::{ RedirectTargetDigestError, }; pub use resolution::{ - ConnectionEvidence, DestinationError, DestinationPolicy, FreshConnectionEvidence, - FreshResolutionSnapshot, MAX_RESOLUTION_ADDRESS_COUNT, MAX_RESOLUTION_VALIDITY, + ConnectionEvidence, DestinationError, DestinationPolicy, MAX_RESOLUTION_ADDRESS_COUNT, ResolutionSnapshot, }; diff --git a/crates/originweave-destination/src/proxy.rs b/crates/originweave-destination/src/proxy.rs index 4695dc3aa..ef64289fa 100644 --- a/crates/originweave-destination/src/proxy.rs +++ b/crates/originweave-destination/src/proxy.rs @@ -446,9 +446,6 @@ fn explicit_port(authority: &str) -> Result, ProxyServerError> { port }; - if !port_text.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(ProxyServerError::InvalidIdentifier); - } let port = port_text .parse::() .map_err(|_error| ProxyServerError::InvalidIdentifier)?; diff --git a/crates/originweave-destination/src/resolution.rs b/crates/originweave-destination/src/resolution.rs index 45620e6cd..f55d1722b 100644 --- a/crates/originweave-destination/src/resolution.rs +++ b/crates/originweave-destination/src/resolution.rs @@ -1,7 +1,6 @@ use std::collections::BTreeSet; use std::fmt; use std::net::IpAddr; -use std::time::Duration; use originweave_core::Origin; @@ -10,13 +9,6 @@ use crate::{AddressClass, ClassifiedAddress, classify_address}; /// The largest resolver answer accepted by one resolution snapshot. pub const MAX_RESOLUTION_ADDRESS_COUNT: usize = 256; -/// The largest freshness interval accepted for one resolution approval. -/// -/// This is an OriginWeave product safety budget, not a DNS protocol validity -/// rule. Callers may choose any smaller non-zero interval appropriate to their -/// resolver and network adapter. -pub const MAX_RESOLUTION_VALIDITY: Duration = Duration::from_secs(30); - /// A fail-closed allow-list of destination address classes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DestinationPolicy { @@ -111,34 +103,6 @@ pub enum DestinationError { /// The newly introduced canonical address. address: IpAddr, }, - /// A freshness interval was zero or exceeded [`MAX_RESOLUTION_VALIDITY`]. - InvalidResolutionValidity { - /// The rejected freshness interval. - validity: Duration, - /// The largest accepted freshness interval. - maximum_validity: Duration, - }, - /// Adding the freshness interval to the approval time overflowed. - ResolutionValidityOverflow { - /// The trusted monotonic time at which the answer was approved. - approved_at: Duration, - /// The requested freshness interval. - validity: Duration, - }, - /// A caller supplied a monotonic time earlier than the recorded approval. - ResolutionUseBeforeApproval { - /// The recorded approval time. - approved_at: Duration, - /// The caller-supplied current time. - current_time: Duration, - }, - /// A bounded resolution approval reached its exclusive validity deadline. - ResolutionApprovalExpired { - /// The exclusive upper bound of the approval interval. - valid_until: Duration, - /// The caller-supplied current time. - current_time: Duration, - }, } impl fmt::Display for DestinationError { @@ -178,34 +142,6 @@ impl fmt::Display for DestinationError { formatter, "refreshed DNS answer introduced unapproved address {address}", ), - Self::InvalidResolutionValidity { - validity, - maximum_validity, - } => write!( - formatter, - "resolution validity {validity:?} is outside 1ns..={maximum_validity:?}", - ), - Self::ResolutionValidityOverflow { - approved_at, - validity, - } => write!( - formatter, - "resolution validity {validity:?} overflows approval time {approved_at:?}", - ), - Self::ResolutionUseBeforeApproval { - approved_at, - current_time, - } => write!( - formatter, - "resolution use time {current_time:?} precedes approval time {approved_at:?}", - ), - Self::ResolutionApprovalExpired { - valid_until, - current_time, - } => write!( - formatter, - "resolution approval expired at {valid_until:?}; current time is {current_time:?}", - ), } } } @@ -318,143 +254,6 @@ impl ResolutionSnapshot { } } -/// A resolution snapshot bound to one explicit trusted monotonic validity window. -/// -/// The time values are opaque durations from one caller-owned monotonic clock -/// domain. This type never reads a wall clock itself. Constructing a new fresh -/// snapshot always reruns the same destination validation used by -/// [`ResolutionSnapshot`], so callers cannot renew authority without presenting -/// another policy-valid answer. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FreshResolutionSnapshot { - snapshot: ResolutionSnapshot, - approved_at: Duration, - validity: Duration, - valid_until: Duration, -} - -impl FreshResolutionSnapshot { - /// Validate addresses and bind the resulting snapshot to a bounded lifetime. - pub fn approve( - origin: Origin, - addresses: impl IntoIterator, - policy: &DestinationPolicy, - approved_at: Duration, - validity: Duration, - ) -> Result { - let snapshot = ResolutionSnapshot::approve(origin, addresses, policy)?; - Self::from_snapshot(snapshot, approved_at, validity) - } - - fn from_snapshot( - snapshot: ResolutionSnapshot, - approved_at: Duration, - validity: Duration, - ) -> Result { - if validity.is_zero() || validity > MAX_RESOLUTION_VALIDITY { - return Err(DestinationError::InvalidResolutionValidity { - validity, - maximum_validity: MAX_RESOLUTION_VALIDITY, - }); - } - let Some(valid_until) = approved_at.checked_add(validity) else { - return Err(DestinationError::ResolutionValidityOverflow { - approved_at, - validity, - }); - }; - Ok(Self { - snapshot, - approved_at, - validity, - valid_until, - }) - } - - /// Return the logical origin whose DNS answer was approved. - #[must_use] - pub const fn origin(&self) -> &Origin { - self.snapshot.origin() - } - - /// Return the canonical addresses pinned for this fresh snapshot. - #[must_use] - pub const fn addresses(&self) -> &BTreeSet { - self.snapshot.addresses() - } - - /// Return the trusted monotonic approval time. - #[must_use] - pub const fn approved_at(&self) -> Duration { - self.approved_at - } - - /// Return the configured non-zero validity budget. - #[must_use] - pub const fn validity(&self) -> Duration { - self.validity - } - - /// Return the exclusive upper bound of the approval interval. - #[must_use] - pub const fn valid_until(&self) -> Duration { - self.valid_until - } - - /// Authorize one pinned address only while the freshness window is valid. - pub fn authorize_connection( - &self, - address: IpAddr, - current_time: Duration, - ) -> Result { - self.validate_current_time(current_time)?; - let connection = self.snapshot.authorize_connection(address)?; - Ok(FreshConnectionEvidence { - connection, - resolution_approved_at: self.approved_at, - resolution_valid_until: self.valid_until, - authorized_at: current_time, - }) - } - - /// Revalidate a fresh answer and renew the same bounded validity budget. - /// - /// `revalidated_at` must come from the same monotonic clock domain and may - /// not precede this snapshot's approval time. Expansion of the pinned set - /// remains fail-closed under [`ResolutionSnapshot::revalidate`]. - pub fn revalidate( - &self, - addresses: impl IntoIterator, - policy: &DestinationPolicy, - revalidated_at: Duration, - ) -> Result { - if revalidated_at < self.approved_at { - return Err(DestinationError::ResolutionUseBeforeApproval { - approved_at: self.approved_at, - current_time: revalidated_at, - }); - } - let snapshot = self.snapshot.revalidate(addresses, policy)?; - Self::from_snapshot(snapshot, revalidated_at, self.validity) - } - - fn validate_current_time(&self, current_time: Duration) -> Result<(), DestinationError> { - if current_time < self.approved_at { - return Err(DestinationError::ResolutionUseBeforeApproval { - approved_at: self.approved_at, - current_time, - }); - } - if current_time >= self.valid_until { - return Err(DestinationError::ResolutionApprovalExpired { - valid_until: self.valid_until, - current_time, - }); - } - Ok(()) - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OriginHostConstraint { Domain, @@ -545,38 +344,3 @@ impl ConnectionEvidence { self.address_class } } - -/// Credential-free evidence that a pinned connection address was used while fresh. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FreshConnectionEvidence { - connection: ConnectionEvidence, - resolution_approved_at: Duration, - resolution_valid_until: Duration, - authorized_at: Duration, -} - -impl FreshConnectionEvidence { - /// Return the underlying canonical destination/connection evidence. - #[must_use] - pub const fn connection_evidence(&self) -> &ConnectionEvidence { - &self.connection - } - - /// Return the trusted monotonic time at which the answer was approved. - #[must_use] - pub const fn resolution_approved_at(&self) -> Duration { - self.resolution_approved_at - } - - /// Return the exclusive upper bound of the resolution approval interval. - #[must_use] - pub const fn resolution_valid_until(&self) -> Duration { - self.resolution_valid_until - } - - /// Return the trusted monotonic time used for this authorization decision. - #[must_use] - pub const fn authorized_at(&self) -> Duration { - self.authorized_at - } -} diff --git a/crates/originweave-destination/tests/proxy_port_syntax.rs b/crates/originweave-destination/tests/proxy_port_syntax.rs deleted file mode 100644 index 9038c14ed..000000000 --- a/crates/originweave-destination/tests/proxy_port_syntax.rs +++ /dev/null @@ -1,29 +0,0 @@ -use originweave_destination::{ProxyServer, ProxyServerError}; - -#[test] -fn proxy_server_rejects_non_digit_port_prefixes() { - for input in [ - "proxy.example:+8080", - "http://proxy.example:+8080", - "https://proxy.example:+8443", - "socks5://proxy.example:+1080", - "https://[2001:db8::1]:+8443", - ] { - assert_eq!( - ProxyServer::parse(input), - Err(ProxyServerError::InvalidIdentifier), - "input={input}", - ); - } -} - -#[test] -fn proxy_server_rejects_decimal_ports_outside_u16_range() { - for input in ["proxy.example:65536", "https://[2001:db8::1]:65536"] { - assert_eq!( - ProxyServer::parse(input), - Err(ProxyServerError::InvalidIdentifier), - "input={input}", - ); - } -} diff --git a/crates/originweave-destination/tests/resolution_freshness.rs b/crates/originweave-destination/tests/resolution_freshness.rs deleted file mode 100644 index 2df264563..000000000 --- a/crates/originweave-destination/tests/resolution_freshness.rs +++ /dev/null @@ -1,235 +0,0 @@ -#![allow(clippy::expect_used)] - -use std::net::{IpAddr, Ipv4Addr}; -use std::time::Duration; - -use originweave_core::Origin; -use originweave_destination::{ - AddressClass, DestinationError, DestinationPolicy, FreshResolutionSnapshot, - MAX_RESOLUTION_VALIDITY, -}; - -fn origin(value: &str) -> Origin { - Origin::parse(value).expect("test origin must parse") -} - -fn ipv4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { - IpAddr::V4(Ipv4Addr::new(a, b, c, d)) -} - -#[test] -fn fresh_resolution_authority_is_half_open_and_bound_to_pinned_addresses() { - let approved_at = Duration::from_secs(100); - let validity = Duration::from_secs(5); - let target = origin("https://example.com"); - let approved = ipv4(8, 8, 8, 8); - let snapshot = FreshResolutionSnapshot::approve( - target.clone(), - [approved], - &DestinationPolicy::public_web(), - approved_at, - validity, - ) - .expect("bounded fresh resolution"); - - assert_eq!(snapshot.origin(), &target); - assert_eq!(snapshot.approved_at(), approved_at); - assert_eq!(snapshot.validity(), validity); - assert_eq!(snapshot.valid_until(), Duration::from_secs(105)); - - let evidence = snapshot - .authorize_connection(approved, approved_at) - .expect("authority begins at approval time"); - let connection = evidence.connection_evidence(); - assert_eq!(connection.origin(), &target); - assert_eq!(connection.requested_address(), approved); - assert_eq!(connection.canonical_address(), approved); - assert_eq!(connection.address_class(), AddressClass::Public); - assert_eq!(evidence.resolution_approved_at(), approved_at); - assert_eq!(evidence.resolution_valid_until(), Duration::from_secs(105)); - assert_eq!(evidence.authorized_at(), approved_at); - - snapshot - .authorize_connection(approved, Duration::from_secs(104)) - .expect("authority remains valid before the exclusive deadline"); - - assert_eq!( - snapshot.authorize_connection(approved, Duration::from_secs(99)), - Err(DestinationError::ResolutionUseBeforeApproval { - approved_at, - current_time: Duration::from_secs(99), - }) - ); - assert_eq!( - snapshot.authorize_connection(approved, Duration::from_secs(105)), - Err(DestinationError::ResolutionApprovalExpired { - valid_until: Duration::from_secs(105), - current_time: Duration::from_secs(105), - }) - ); - assert_eq!( - snapshot.authorize_connection(ipv4(9, 9, 9, 9), approved_at), - Err(DestinationError::UnapprovedConnectionAddress { - address: ipv4(9, 9, 9, 9), - }) - ); -} - -#[test] -fn fresh_resolution_rejects_invalid_or_overflowing_validity() { - let target = origin("https://example.com"); - let address = ipv4(8, 8, 8, 8); - let policy = DestinationPolicy::public_web(); - - for validity in [ - Duration::ZERO, - MAX_RESOLUTION_VALIDITY + Duration::from_nanos(1), - ] { - assert_eq!( - FreshResolutionSnapshot::approve( - target.clone(), - [address], - &policy, - Duration::from_secs(1), - validity, - ), - Err(DestinationError::InvalidResolutionValidity { - validity, - maximum_validity: MAX_RESOLUTION_VALIDITY, - }) - ); - } - - assert_eq!( - FreshResolutionSnapshot::approve( - target, - [address], - &policy, - Duration::MAX, - Duration::from_nanos(1), - ), - Err(DestinationError::ResolutionValidityOverflow { - approved_at: Duration::MAX, - validity: Duration::from_nanos(1), - }) - ); -} - -#[test] -fn fresh_resolution_rejects_denied_addresses_before_granting_time_authority() { - let target = origin("https://example.com"); - let denied = ipv4(127, 0, 0, 1); - let public = ipv4(8, 8, 8, 8); - let policy = DestinationPolicy::public_web(); - let expected = Err(DestinationError::AddressClassDenied { - address: denied, - address_class: AddressClass::Loopback, - }); - - assert_eq!( - FreshResolutionSnapshot::approve( - target.clone(), - [denied], - &policy, - Duration::from_secs(1), - Duration::from_secs(1), - ), - expected.clone() - ); - assert_eq!( - FreshResolutionSnapshot::approve( - target, - [denied, public], - &policy, - Duration::from_secs(1), - Duration::from_secs(1), - ), - expected - ); -} - -#[test] -fn fresh_revalidation_preserves_the_budget_and_resets_approval_time() { - let first = ipv4(8, 8, 8, 8); - let second = ipv4(1, 1, 1, 1); - let unexpected = ipv4(9, 9, 9, 9); - let policy = DestinationPolicy::public_web(); - let snapshot = FreshResolutionSnapshot::approve( - origin("https://example.com"), - [first, second], - &policy, - Duration::from_secs(10), - Duration::from_secs(4), - ) - .expect("initial fresh resolution"); - - let refreshed = snapshot - .revalidate([second], &policy, Duration::from_secs(13)) - .expect("a fresh non-expanding answer renews the bounded window"); - assert_eq!( - refreshed.addresses(), - &std::collections::BTreeSet::from([second]) - ); - assert_eq!(refreshed.approved_at(), Duration::from_secs(13)); - assert_eq!(refreshed.validity(), Duration::from_secs(4)); - assert_eq!(refreshed.valid_until(), Duration::from_secs(17)); - refreshed - .authorize_connection(second, Duration::from_secs(16)) - .expect("refreshed authority is usable before its new deadline"); - - assert_eq!( - snapshot.revalidate([second], &policy, Duration::from_secs(9)), - Err(DestinationError::ResolutionUseBeforeApproval { - approved_at: Duration::from_secs(10), - current_time: Duration::from_secs(9), - }) - ); - assert_eq!( - snapshot.revalidate([unexpected], &policy, Duration::from_secs(11)), - Err(DestinationError::ResolutionSetExpanded { - address: unexpected, - }) - ); - assert_eq!( - snapshot.revalidate([first, unexpected], &policy, Duration::from_secs(11)), - Err(DestinationError::ResolutionSetExpanded { - address: unexpected, - }) - ); -} - -#[test] -fn freshness_errors_have_deterministic_bounded_messages() { - let invalid = DestinationError::InvalidResolutionValidity { - validity: Duration::ZERO, - maximum_validity: MAX_RESOLUTION_VALIDITY, - }; - assert_eq!( - invalid.to_string(), - "resolution validity 0ns is outside 1ns..=30s" - ); - - let overflow = DestinationError::ResolutionValidityOverflow { - approved_at: Duration::MAX, - validity: Duration::from_nanos(1), - }; - assert!(overflow.to_string().contains("overflows approval time")); - - let before = DestinationError::ResolutionUseBeforeApproval { - approved_at: Duration::from_secs(10), - current_time: Duration::from_secs(9), - }; - assert_eq!( - before.to_string(), - "resolution use time 9s precedes approval time 10s" - ); - - let expired = DestinationError::ResolutionApprovalExpired { - valid_until: Duration::from_secs(15), - current_time: Duration::from_secs(15), - }; - assert_eq!( - expired.to_string(), - "resolution approval expired at 15s; current time is 15s" - ); -} diff --git a/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs b/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs deleted file mode 100644 index 3c8443554..000000000 --- a/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs +++ /dev/null @@ -1,78 +0,0 @@ -#![allow(clippy::expect_used)] - -use std::net::{IpAddr, Ipv4Addr}; -use std::time::Duration; - -use originweave_core::Origin; -use originweave_destination::{DestinationError, DestinationPolicy, FreshResolutionSnapshot}; - -fn origin() -> Origin { - Origin::parse("https://example.com").expect("test origin must parse") -} - -fn ipv4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { - IpAddr::V4(Ipv4Addr::new(a, b, c, d)) -} - -#[test] -fn post_expiry_revalidation_establishes_new_authority_without_reviving_the_old_snapshot() { - let first = ipv4(8, 8, 8, 8); - let second = ipv4(1, 1, 1, 1); - let policy = DestinationPolicy::public_web(); - let snapshot = FreshResolutionSnapshot::approve( - origin(), - [first, second], - &policy, - Duration::from_secs(10), - Duration::from_secs(4), - ) - .expect("initial bounded freshness authority"); - - let expiry = Duration::from_secs(14); - assert_eq!( - snapshot.authorize_connection(first, expiry), - Err(DestinationError::ResolutionApprovalExpired { - valid_until: expiry, - current_time: expiry, - }) - ); - - let refreshed = snapshot - .revalidate([second], &policy, expiry) - .expect("fresh non-expanding validation may establish a new bounded snapshot"); - assert_eq!(refreshed.approved_at(), expiry); - assert_eq!(refreshed.valid_until(), Duration::from_secs(18)); - refreshed - .authorize_connection(second, expiry) - .expect("the newly validated snapshot has independent current authority"); - - assert_eq!( - snapshot.authorize_connection(second, expiry), - Err(DestinationError::ResolutionApprovalExpired { - valid_until: expiry, - current_time: expiry, - }) - ); -} - -#[test] -fn post_expiry_revalidation_still_rejects_address_set_expansion() { - let approved = ipv4(8, 8, 8, 8); - let unexpected = ipv4(9, 9, 9, 9); - let policy = DestinationPolicy::public_web(); - let snapshot = FreshResolutionSnapshot::approve( - origin(), - [approved], - &policy, - Duration::from_secs(10), - Duration::from_secs(4), - ) - .expect("initial bounded freshness authority"); - - assert_eq!( - snapshot.revalidate([approved, unexpected], &policy, Duration::from_secs(14)), - Err(DestinationError::ResolutionSetExpanded { - address: unexpected, - }) - ); -} diff --git a/crates/originweave-evidence/src/extraction_schema.rs b/crates/originweave-evidence/src/extraction_schema.rs deleted file mode 100644 index 14a86a24c..000000000 --- a/crates/originweave-evidence/src/extraction_schema.rs +++ /dev/null @@ -1,297 +0,0 @@ -//! Versioned schema contracts for typed evidence extraction. -//! -//! These value objects describe what may be extracted and which reviewed -//! evidence channels may support each field. They do not read browser data, -//! disclose protected values, persist artifacts, execute models, or grant any -//! browser, network, secret, approval, or storage authority. - -use std::{collections::BTreeSet, fmt}; - -/// Maximum encoded byte length for an extraction schema or field identifier. -pub const MAX_EXTRACTION_IDENTIFIER_BYTES: usize = 128; -/// Maximum number of fields admitted by one extraction schema. -pub const MAX_EXTRACTION_FIELD_COUNT: usize = 256; - -/// The typed value contract for one extracted field. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ExtractionValueType { - /// Bounded textual data. - Text, - /// A whole-number value. - Integer, - /// A decimal numeric value. - Decimal, - /// A boolean value. - Boolean, - /// A timestamp value whose concrete normalization is defined by the schema version. - Timestamp, -} - -/// The number of values admitted for one extracted field. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ExtractionCardinality { - /// Exactly one value is admitted. - One, - /// Zero or one value is admitted. - ZeroOrOne, - /// A bounded collection may be admitted by a later extraction runtime. - Many, -} - -/// A reviewed evidence channel that may support an extracted value. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ExtractionSourceChannel { - /// A semantic browser node with an independently validated identity. - SemanticNode, - /// Embedded structured metadata such as JSON-LD, RDFa, or Microdata. - StructuredData, - /// A bounded table-cell observation. - TableCell, - /// A bounded network response whose origin and response identity are independently verified. - NetworkResponse, - /// A separately approved model interpretation backed by explicit evidence identifiers. - ModelInterpretation, -} - -/// A deterministic normalization rule declared for one extracted field. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ExtractionNormalizationRule { - /// Preserve the typed source value without text normalization. - Verbatim, - /// Trim surrounding whitespace from a textual value. - TrimTextWhitespace, - /// Normalize a timestamp into an RFC 3339 UTC representation. - Rfc3339Utc, -} - -/// A validation failure while constructing an extraction schema contract. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExtractionSchemaError { - /// A schema or field identifier was empty or outside the accepted identifier grammar. - InvalidIdentifier, - /// An identifier or field collection exceeded its bounded limit. - LimitExceeded, - /// A field's required flag contradicted its declared cardinality. - InvalidCardinalityRequirement, - /// A field did not declare any reviewed source channel. - MissingSourceChannel, - /// A field declared the same source channel more than once. - DuplicateSourceChannel, - /// The declared normalization rule was incompatible with the field value type. - InvalidNormalizationRule, - /// A schema did not contain any field definitions. - MissingField, - /// A schema declared the same field identifier more than once. - DuplicateField, -} - -impl fmt::Display for ExtractionSchemaError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self { - Self::InvalidIdentifier => "invalid extraction schema or field identifier", - Self::LimitExceeded => "extraction schema limit exceeded", - Self::InvalidCardinalityRequirement => { - "extraction field required flag is incompatible with the declared cardinality" - } - Self::MissingSourceChannel => "extraction field requires at least one source channel", - Self::DuplicateSourceChannel => "extraction field contains a duplicate source channel", - Self::InvalidNormalizationRule => { - "extraction normalization rule is incompatible with the field value type" - } - Self::MissingField => "extraction schema requires at least one field", - Self::DuplicateField => "extraction schema contains a duplicate field identifier", - }) - } -} - -impl std::error::Error for ExtractionSchemaError {} - -/// One typed field declared by a versioned extraction schema. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExtractionField { - identifier: String, - value_type: ExtractionValueType, - cardinality: ExtractionCardinality, - required: bool, - normalization_rule: ExtractionNormalizationRule, - source_channels: Vec, -} - -impl ExtractionField { - /// Validate and construct one extraction field contract with verbatim normalization. - pub fn new( - identifier: &str, - value_type: ExtractionValueType, - cardinality: ExtractionCardinality, - required: bool, - source_channels: &[ExtractionSourceChannel], - ) -> Result { - Self::new_with_normalization( - identifier, - value_type, - cardinality, - required, - ExtractionNormalizationRule::Verbatim, - source_channels, - ) - } - - /// Validate and construct one extraction field with an explicit normalization rule. - pub fn new_with_normalization( - identifier: &str, - value_type: ExtractionValueType, - cardinality: ExtractionCardinality, - required: bool, - normalization_rule: ExtractionNormalizationRule, - source_channels: &[ExtractionSourceChannel], - ) -> Result { - validate_identifier(identifier)?; - - let cardinality_requirement_is_compatible = match cardinality { - ExtractionCardinality::One => required, - ExtractionCardinality::ZeroOrOne => !required, - ExtractionCardinality::Many => true, - }; - if !cardinality_requirement_is_compatible { - return Err(ExtractionSchemaError::InvalidCardinalityRequirement); - } - - if source_channels.is_empty() { - return Err(ExtractionSchemaError::MissingSourceChannel); - } - - let normalization_is_compatible = match normalization_rule { - ExtractionNormalizationRule::Verbatim => true, - ExtractionNormalizationRule::TrimTextWhitespace => { - value_type == ExtractionValueType::Text - } - ExtractionNormalizationRule::Rfc3339Utc => value_type == ExtractionValueType::Timestamp, - }; - if !normalization_is_compatible { - return Err(ExtractionSchemaError::InvalidNormalizationRule); - } - - let mut seen_channels = BTreeSet::new(); - for source_channel in source_channels { - if !seen_channels.insert(*source_channel) { - return Err(ExtractionSchemaError::DuplicateSourceChannel); - } - } - - Ok(Self { - identifier: identifier.to_owned(), - value_type, - cardinality, - required, - normalization_rule, - source_channels: seen_channels.into_iter().collect(), - }) - } - - /// Return the stable field identifier. - #[must_use] - pub fn identifier(&self) -> &str { - &self.identifier - } - - /// Return the declared value type. - #[must_use] - pub const fn value_type(&self) -> ExtractionValueType { - self.value_type - } - - /// Return the declared cardinality. - #[must_use] - pub const fn cardinality(&self) -> ExtractionCardinality { - self.cardinality - } - - /// Return whether the field must be present in a conforming extraction result. - #[must_use] - pub const fn required(&self) -> bool { - self.required - } - - /// Return the deterministic normalization rule declared for this field. - #[must_use] - pub const fn normalization_rule(&self) -> ExtractionNormalizationRule { - self.normalization_rule - } - - /// Return the reviewed source channels that may support this field. - #[must_use] - pub fn source_channels(&self) -> &[ExtractionSourceChannel] { - &self.source_channels - } -} - -/// A bounded versioned collection of typed extraction-field contracts. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExtractionSchema { - version: String, - fields: Vec, -} - -impl ExtractionSchema { - /// Validate and construct one versioned extraction schema. - pub fn new(version: &str, fields: Vec) -> Result { - validate_identifier(version)?; - if fields.is_empty() { - return Err(ExtractionSchemaError::MissingField); - } - if fields.len() > MAX_EXTRACTION_FIELD_COUNT { - return Err(ExtractionSchemaError::LimitExceeded); - } - - let mut field_identifiers = BTreeSet::new(); - for field in &fields { - if !field_identifiers.insert(field.identifier()) { - return Err(ExtractionSchemaError::DuplicateField); - } - } - - Ok(Self { - version: version.to_owned(), - fields, - }) - } - - /// Return the immutable schema version identifier. - #[must_use] - pub fn version(&self) -> &str { - &self.version - } - - /// Return the schema's ordered field definitions. - #[must_use] - pub fn fields(&self) -> &[ExtractionField] { - &self.fields - } - - /// Find one field by its stable identifier. - #[must_use] - pub fn field(&self, identifier: &str) -> Option<&ExtractionField> { - self.fields - .iter() - .find(|field| field.identifier() == identifier) - } -} - -fn validate_identifier(identifier: &str) -> Result<(), ExtractionSchemaError> { - if identifier.len() > MAX_EXTRACTION_IDENTIFIER_BYTES { - return Err(ExtractionSchemaError::LimitExceeded); - } - - let mut bytes = identifier.bytes(); - let Some(first_byte) = bytes.next() else { - return Err(ExtractionSchemaError::InvalidIdentifier); - }; - if !first_byte.is_ascii_lowercase() { - return Err(ExtractionSchemaError::InvalidIdentifier); - } - if bytes.any(|byte| !matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')) { - return Err(ExtractionSchemaError::InvalidIdentifier); - } - - Ok(()) -} diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index 17c97bec8..406c8be03 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -7,27 +7,20 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] -mod extraction_schema; mod sensitive_access; -mod sensitive_handle_lifecycle; -pub use extraction_schema::{ - ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchema, - ExtractionSchemaError, ExtractionSourceChannel, ExtractionValueType, - MAX_EXTRACTION_FIELD_COUNT, MAX_EXTRACTION_IDENTIFIER_BYTES, -}; pub use sensitive_access::{ MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, SensitiveAccessOutcome, SensitiveEvidenceError, }; -pub use sensitive_handle_lifecycle::{ - SensitiveHandleLifecycleEvidence, SensitiveHandleLifecycleEvidenceInput, -}; use std::collections::BTreeMap; -use originweave_core::Origin; +use originweave_core::{ + BrowserProtocolCapability, BrowserProtocolKind, Origin, OriginWeaveProtocolVersion, + ValidatedBrowserProtocolUse, +}; const REDACTED: &str = "[REDACTED]"; @@ -44,6 +37,75 @@ pub const MAX_METADATA_VALUE_BYTES: usize = 8_192; /// Maximum source URL or source-locator size retained in provenance metadata. pub const MAX_PROVENANCE_TEXT_BYTES: usize = 8_192; +/// Immutable credential-safe audit metadata for one validated browser protocol use. +/// +/// This value can only be constructed from [`ValidatedBrowserProtocolUse`], so +/// it records metadata that already passed the exact OriginWeave generation, +/// runtime protocol-family, pinned runtime-revision, and capability checks. It +/// intentionally remains ordinary cloneable evidence: cloning this value does +/// not recreate the non-cloneable validation prerequisite or grant browser, +/// Agent, origin, session, context, network, or secret authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserProtocolValidationEvidence { + kind: BrowserProtocolKind, + originweave_protocol_version: OriginWeaveProtocolVersion, + adapter_version: String, + protocol_revision: String, + browser_revision: String, + capability: BrowserProtocolCapability, +} + +impl BrowserProtocolValidationEvidence { + /// Record owned audit metadata from one already validated browser protocol use. + #[must_use] + pub fn from_validated_use(validated: &ValidatedBrowserProtocolUse) -> Self { + Self { + kind: validated.kind(), + originweave_protocol_version: validated.originweave_protocol_version(), + adapter_version: validated.adapter_version().to_owned(), + protocol_revision: validated.protocol_revision().to_owned(), + browser_revision: validated.browser_revision().to_owned(), + capability: validated.capability(), + } + } + + /// Return the validated browser protocol family. + #[must_use] + pub const fn kind(&self) -> BrowserProtocolKind { + self.kind + } + + /// Return the validated OriginWeave Protocol generation. + #[must_use] + pub const fn originweave_protocol_version(&self) -> OriginWeaveProtocolVersion { + self.originweave_protocol_version + } + + /// Return the bounded validated adapter-version metadata token. + #[must_use] + pub fn adapter_version(&self) -> &str { + &self.adapter_version + } + + /// Return the bounded validated upstream protocol-revision metadata token. + #[must_use] + pub fn protocol_revision(&self) -> &str { + &self.protocol_revision + } + + /// Return the bounded validated browser-revision metadata token. + #[must_use] + pub fn browser_revision(&self) -> &str { + &self.browser_revision + } + + /// Return the exact browser protocol capability validated for this use. + #[must_use] + pub const fn capability(&self) -> BrowserProtocolCapability { + self.capability + } +} + /// An HTTP method recorded for network evidence. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum HttpMethod { @@ -234,9 +296,6 @@ fn validate_path(path: &str) -> Result<(), EvidenceError> { index += 3; continue; } - if !is_rfc3986_pchar(byte) { - return Err(EvidenceError::InvalidPath); - } segment.push(byte); index += 1; } @@ -246,32 +305,6 @@ fn validate_path(path: &str) -> Result<(), EvidenceError> { Ok(()) } -const fn is_rfc3986_pchar(byte: u8) -> bool { - matches!( - byte, - b'A'..=b'Z' - | b'a'..=b'z' - | b'0'..=b'9' - | b'-' - | b'.' - | b'_' - | b'~' - | b'!' - | b'$' - | b'&' - | b'\'' - | b'(' - | b')' - | b'*' - | b'+' - | b',' - | b';' - | b'=' - | b':' - | b'@' - ) -} - const fn hexadecimal_value(byte: u8) -> Option { match byte { b'0'..=b'9' => Some(byte - b'0'), diff --git a/crates/originweave-evidence/src/sensitive_access.rs b/crates/originweave-evidence/src/sensitive_access.rs index 24cb43047..9123119f7 100644 --- a/crates/originweave-evidence/src/sensitive_access.rs +++ b/crates/originweave-evidence/src/sensitive_access.rs @@ -297,10 +297,7 @@ fn validate_fields(field_ids: &[String]) -> Result<(), SensitiveEvidenceError> { Ok(()) } -/// Return whether `value` is a non-empty identifier of at most -/// `MAX_SENSITIVE_IDENTIFIER_BYTES` ASCII bytes, contains at least one -/// alphanumeric byte, and otherwise uses only `.`, `_`, `:`, or `-` punctuation. -pub(crate) fn valid_identifier(value: &str) -> bool { +fn valid_identifier(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_SENSITIVE_IDENTIFIER_BYTES && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) diff --git a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs deleted file mode 100644 index f61c8527f..000000000 --- a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! Credential-free lifecycle evidence for opaque sensitive-value handles. -//! -//! A trusted broker can use this value object to record when a handle was -//! issued, when it expires, how many uses it permits, how many resolutions were -//! observed, and when it was revoked. The lifecycle retains the complete -//! credential-free sensitive-access receipt that authorized opaque-handle use, -//! while intentionally excluding the opaque handle token and protected value. - -use crate::sensitive_access::{ - SensitiveAccessEvidence, SensitiveAccessOutcome, SensitiveEvidenceError, -}; - -/// Unvalidated metadata describing one opaque sensitive-value handle lifecycle. -/// -/// The embedded access receipt binds the lifecycle to the tenant, actor, task, -/// field set, purpose, destination, classification, policy version, and exact -/// opaque-handle authorization without carrying protected values. When the access -/// receipt carries a retention deadline, the handle must expire no later than -/// that deadline so derived opaque authority cannot outlive its governing receipt. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SensitiveHandleLifecycleEvidenceInput { - /// Credential-free access receipt that authorized this opaque handle. - pub access_evidence: SensitiveAccessEvidence, - /// Trusted Unix epoch second when the handle was issued. - pub issued_epoch_seconds: u64, - /// Trusted Unix epoch second after which the handle is no longer valid. - /// - /// When the retained access receipt defines a retention deadline, this value - /// may equal but must not exceed that deadline. - pub expires_epoch_seconds: u64, - /// Maximum number of broker resolutions authorized for the handle. - pub maximum_uses: u32, - /// Number of broker resolutions already observed for the handle. - pub resolution_count: u32, - /// Trusted Unix epoch second when the handle was revoked, when applicable. - /// - /// A revocation recorded exactly at expiry is retained as a terminal audit - /// event even though it cannot extend or restore handle validity. - pub revoked_epoch_seconds: Option, -} - -/// Immutable credential-free evidence about one opaque handle lifecycle. -/// -/// The value retains the exact credential-free sensitive-access receipt that -/// authorized opaque-handle use, but deliberately excludes both the opaque -/// handle token and the secret or protected value that the broker can resolve. -/// Any receipt retention deadline also bounds the derived handle lifetime. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SensitiveHandleLifecycleEvidence { - access_evidence: SensitiveAccessEvidence, - issued_epoch_seconds: u64, - expires_epoch_seconds: u64, - maximum_uses: u32, - resolution_count: u32, - revoked_epoch_seconds: Option, -} - -impl TryFrom for SensitiveHandleLifecycleEvidence { - type Error = SensitiveEvidenceError; - - fn try_from(input: SensitiveHandleLifecycleEvidenceInput) -> Result { - if input.access_evidence.outcome() != SensitiveAccessOutcome::OpaqueHandleOnly - || input.issued_epoch_seconds == 0 - || input.issued_epoch_seconds < input.access_evidence.decision_epoch_seconds() - || input.expires_epoch_seconds <= input.issued_epoch_seconds - || input - .access_evidence - .retention_deadline_epoch_seconds() - .is_some_and(|deadline| input.expires_epoch_seconds > deadline) - || input.maximum_uses == 0 - || input.resolution_count > input.maximum_uses - || input.revoked_epoch_seconds.is_some_and(|revoked| { - revoked < input.issued_epoch_seconds || revoked > input.expires_epoch_seconds - }) - { - return Err(SensitiveEvidenceError::InvalidLifecycle); - } - - Ok(Self { - access_evidence: input.access_evidence, - issued_epoch_seconds: input.issued_epoch_seconds, - expires_epoch_seconds: input.expires_epoch_seconds, - maximum_uses: input.maximum_uses, - resolution_count: input.resolution_count, - revoked_epoch_seconds: input.revoked_epoch_seconds, - }) - } -} - -impl SensitiveHandleLifecycleEvidence { - /// Return the credential-free access receipt that authorized this opaque handle. - #[must_use] - pub const fn access_evidence(&self) -> &SensitiveAccessEvidence { - &self.access_evidence - } - - /// Return the originating sensitive-data access request identifier. - #[must_use] - pub fn request_id(&self) -> &str { - self.access_evidence.request_id() - } - - /// Return the policy decision identifier associated with the handle. - #[must_use] - pub fn decision_id(&self) -> &str { - self.access_evidence.decision_id() - } - - /// Return the trusted handle issuance time as a Unix epoch second. - #[must_use] - pub const fn issued_epoch_seconds(&self) -> u64 { - self.issued_epoch_seconds - } - - /// Return the trusted handle expiry time as a Unix epoch second. - #[must_use] - pub const fn expires_epoch_seconds(&self) -> u64 { - self.expires_epoch_seconds - } - - /// Return the maximum number of broker resolutions authorized for the handle. - #[must_use] - pub const fn maximum_uses(&self) -> u32 { - self.maximum_uses - } - - /// Return the number of broker resolutions already observed for the handle. - #[must_use] - pub const fn resolution_count(&self) -> u32 { - self.resolution_count - } - - /// Return the trusted revocation time when the handle has been revoked. - #[must_use] - pub const fn revoked_epoch_seconds(&self) -> Option { - self.revoked_epoch_seconds - } - - /// Return whether trusted evidence records that this handle was revoked. - #[must_use] - pub const fn is_revoked(&self) -> bool { - self.revoked_epoch_seconds.is_some() - } -} diff --git a/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs b/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs new file mode 100644 index 000000000..fdab7d12c --- /dev/null +++ b/crates/originweave-evidence/tests/browser_protocol_validation_evidence.rs @@ -0,0 +1,83 @@ +use std::error::Error; + +use originweave_core::{ + BrowserProtocolAdapterDescriptor, BrowserProtocolCapability, BrowserProtocolKind, + OriginWeaveProtocolVersion, +}; +use originweave_evidence::BrowserProtocolValidationEvidence; + +const ORIGINWEAVE_PROTOCOL_VERSION: OriginWeaveProtocolVersion = + OriginWeaveProtocolVersion::new(0, 1); +const ADAPTER_VERSION: &str = "originweave-bidi-v1"; +const PROTOCOL_REVISION: &str = "webdriver-bidi-wd-2026-06-01"; +const BROWSER_REVISION: &str = "chromium-r1639810"; + +#[test] +fn records_exact_metadata_from_one_validated_browser_protocol_use() -> Result<(), Box> { + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::WebDriverBiDi, + ORIGINWEAVE_PROTOCOL_VERSION, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + &[BrowserProtocolCapability::SemanticObservation], + )?; + let validated = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::WebDriverBiDi, + ADAPTER_VERSION, + PROTOCOL_REVISION, + BROWSER_REVISION, + BrowserProtocolCapability::SemanticObservation, + )?; + + let evidence = BrowserProtocolValidationEvidence::from_validated_use(&validated); + + assert_eq!(evidence.kind(), BrowserProtocolKind::WebDriverBiDi); + assert_eq!( + evidence.originweave_protocol_version(), + ORIGINWEAVE_PROTOCOL_VERSION + ); + assert_eq!(evidence.adapter_version(), ADAPTER_VERSION); + assert_eq!(evidence.protocol_revision(), PROTOCOL_REVISION); + assert_eq!(evidence.browser_revision(), BROWSER_REVISION); + assert_eq!( + evidence.capability(), + BrowserProtocolCapability::SemanticObservation + ); + Ok(()) +} + +#[test] +fn evidence_is_owned_audit_metadata_not_reusable_validation_authority() -> Result<(), Box> +{ + let cdp_adapter_version = "originweave-cdp-v1"; + let cdp_protocol_revision = "cdp-1-3-r1639810"; + let descriptor = BrowserProtocolAdapterDescriptor::new( + BrowserProtocolKind::ChromeDevToolsProtocol, + ORIGINWEAVE_PROTOCOL_VERSION, + cdp_adapter_version, + cdp_protocol_revision, + BROWSER_REVISION, + &[BrowserProtocolCapability::NetworkObservation], + )?; + let validated = descriptor.validate_use( + ORIGINWEAVE_PROTOCOL_VERSION, + BrowserProtocolKind::ChromeDevToolsProtocol, + cdp_adapter_version, + cdp_protocol_revision, + BROWSER_REVISION, + BrowserProtocolCapability::NetworkObservation, + )?; + + let evidence = BrowserProtocolValidationEvidence::from_validated_use(&validated); + let cloned = evidence.clone(); + + assert_eq!(cloned, evidence); + assert_eq!(cloned.kind(), BrowserProtocolKind::ChromeDevToolsProtocol); + assert_eq!( + cloned.capability(), + BrowserProtocolCapability::NetworkObservation + ); + Ok(()) +} diff --git a/crates/originweave-evidence/tests/evidence.rs b/crates/originweave-evidence/tests/evidence.rs index 48d49cbc4..2912180f1 100644 --- a/crates/originweave-evidence/tests/evidence.rs +++ b/crates/originweave-evidence/tests/evidence.rs @@ -80,9 +80,6 @@ fn network_evidence_rejects_non_path_inputs() { "/bad\npath", "/bad path", "/windows\\path", - "/[segment]", - "/raw|pipe", - "/raw-한글", ] { assert_eq!( NetworkEvidence::capture( @@ -131,7 +128,6 @@ fn provenance_rejects_credential_bearing_or_ambiguous_source_urls() { "https://example.com/bad\\path", "https://example.com/\n", "https://example.com/a/%2f/b", - "https://example.com/[segment]", ] { assert_eq!( ProvenanceRecord::new( diff --git a/crates/originweave-evidence/tests/extraction_normalization.rs b/crates/originweave-evidence/tests/extraction_normalization.rs deleted file mode 100644 index 63afd39e6..000000000 --- a/crates/originweave-evidence/tests/extraction_normalization.rs +++ /dev/null @@ -1,77 +0,0 @@ -use originweave_evidence::{ - ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchemaError, - ExtractionSourceChannel, ExtractionValueType, -}; - -#[test] -fn extraction_fields_require_an_explicit_typed_normalization_rule() --> Result<(), ExtractionSchemaError> { - let text = ExtractionField::new_with_normalization( - "product_name", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - ExtractionNormalizationRule::TrimTextWhitespace, - &[ExtractionSourceChannel::SemanticNode], - )?; - assert_eq!( - text.normalization_rule(), - ExtractionNormalizationRule::TrimTextWhitespace - ); - - let timestamp = ExtractionField::new_with_normalization( - "captured_at", - ExtractionValueType::Timestamp, - ExtractionCardinality::One, - true, - ExtractionNormalizationRule::Rfc3339Utc, - &[ExtractionSourceChannel::NetworkResponse], - )?; - assert_eq!( - timestamp.normalization_rule(), - ExtractionNormalizationRule::Rfc3339Utc - ); - Ok(()) -} - -#[test] -fn extraction_fields_fail_closed_on_type_incompatible_normalization() { - assert_eq!( - ExtractionField::new_with_normalization( - "captured_at", - ExtractionValueType::Timestamp, - ExtractionCardinality::One, - true, - ExtractionNormalizationRule::TrimTextWhitespace, - &[ExtractionSourceChannel::NetworkResponse], - ), - Err(ExtractionSchemaError::InvalidNormalizationRule) - ); - assert_eq!( - ExtractionField::new_with_normalization( - "product_name", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - ExtractionNormalizationRule::Rfc3339Utc, - &[ExtractionSourceChannel::SemanticNode], - ), - Err(ExtractionSchemaError::InvalidNormalizationRule) - ); -} - -#[test] -fn existing_fields_default_to_verbatim_normalization() -> Result<(), ExtractionSchemaError> { - let field = ExtractionField::new( - "unit_price", - ExtractionValueType::Decimal, - ExtractionCardinality::ZeroOrOne, - false, - &[ExtractionSourceChannel::StructuredData], - )?; - assert_eq!( - field.normalization_rule(), - ExtractionNormalizationRule::Verbatim - ); - Ok(()) -} diff --git a/crates/originweave-evidence/tests/extraction_schema.rs b/crates/originweave-evidence/tests/extraction_schema.rs deleted file mode 100644 index fc875ef0f..000000000 --- a/crates/originweave-evidence/tests/extraction_schema.rs +++ /dev/null @@ -1,326 +0,0 @@ -use originweave_evidence::{ - ExtractionCardinality, ExtractionField, ExtractionSchema, ExtractionSchemaError, - ExtractionSourceChannel, ExtractionValueType, MAX_EXTRACTION_FIELD_COUNT, - MAX_EXTRACTION_IDENTIFIER_BYTES, -}; - -fn field( - identifier: &str, - value_type: ExtractionValueType, - cardinality: ExtractionCardinality, - required: bool, - source_channels: &[ExtractionSourceChannel], -) -> Result { - ExtractionField::new( - identifier, - value_type, - cardinality, - required, - source_channels, - ) -} - -#[test] -fn schema_binds_versioned_typed_fields_to_explicit_source_channels() --> Result<(), ExtractionSchemaError> { - let schema = ExtractionSchema::new( - "product-card-v1", - vec![ - field( - "product_name", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - &[ - ExtractionSourceChannel::SemanticNode, - ExtractionSourceChannel::StructuredData, - ], - )?, - field( - "unit_price", - ExtractionValueType::Decimal, - ExtractionCardinality::ZeroOrOne, - false, - &[ - ExtractionSourceChannel::TableCell, - ExtractionSourceChannel::NetworkResponse, - ], - )?, - ], - )?; - - assert_eq!(schema.version(), "product-card-v1"); - assert_eq!(schema.fields().len(), 2); - assert_eq!( - schema - .field("product_name") - .map(ExtractionField::identifier), - Some("product_name") - ); - assert_eq!( - schema - .field("product_name") - .map(ExtractionField::value_type), - Some(ExtractionValueType::Text) - ); - assert_eq!( - schema - .field("product_name") - .map(ExtractionField::cardinality), - Some(ExtractionCardinality::One) - ); - assert_eq!( - schema.field("product_name").map(ExtractionField::required), - Some(true) - ); - let expected_product_sources = [ - ExtractionSourceChannel::SemanticNode, - ExtractionSourceChannel::StructuredData, - ]; - assert_eq!( - schema - .field("product_name") - .map(ExtractionField::source_channels), - Some(expected_product_sources.as_slice()) - ); - assert_eq!( - schema.field("unit_price").map(ExtractionField::value_type), - Some(ExtractionValueType::Decimal) - ); - assert_eq!( - schema.field("unit_price").map(ExtractionField::cardinality), - Some(ExtractionCardinality::ZeroOrOne) - ); - assert_eq!( - schema.field("unit_price").map(ExtractionField::required), - Some(false) - ); - assert!(schema.field("missing_field").is_none()); - Ok(()) -} - -#[test] -fn field_accepts_all_reviewed_value_and_source_channel_variants() --> Result<(), ExtractionSchemaError> { - let cases = [ - ( - ExtractionValueType::Text, - ExtractionSourceChannel::SemanticNode, - ), - ( - ExtractionValueType::Integer, - ExtractionSourceChannel::StructuredData, - ), - ( - ExtractionValueType::Decimal, - ExtractionSourceChannel::TableCell, - ), - ( - ExtractionValueType::Boolean, - ExtractionSourceChannel::NetworkResponse, - ), - ( - ExtractionValueType::Timestamp, - ExtractionSourceChannel::ModelInterpretation, - ), - ]; - - for (index, (value_type, source_channel)) in cases.into_iter().enumerate() { - let field = field( - &format!("field_{index}"), - value_type, - ExtractionCardinality::Many, - false, - &[source_channel], - )?; - assert_eq!(field.value_type(), value_type); - assert_eq!(field.cardinality(), ExtractionCardinality::Many); - assert_eq!(field.source_channels(), &[source_channel]); - } - - let required_many = field( - "required_many", - ExtractionValueType::Text, - ExtractionCardinality::Many, - true, - &[ExtractionSourceChannel::SemanticNode], - )?; - assert!(required_many.required()); - Ok(()) -} - -#[test] -fn field_rejects_contradictory_required_cardinality_contracts() { - assert_eq!( - ExtractionField::new( - "optional_exactly_one", - ExtractionValueType::Text, - ExtractionCardinality::One, - false, - &[ExtractionSourceChannel::SemanticNode], - ), - Err(ExtractionSchemaError::InvalidCardinalityRequirement) - ); - assert_eq!( - ExtractionField::new( - "required_zero_or_one", - ExtractionValueType::Text, - ExtractionCardinality::ZeroOrOne, - true, - &[ExtractionSourceChannel::SemanticNode], - ), - Err(ExtractionSchemaError::InvalidCardinalityRequirement) - ); -} - -#[test] -fn field_rejects_empty_malformed_or_overlong_identifiers() { - assert_eq!( - ExtractionField::new( - "", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - &[ExtractionSourceChannel::SemanticNode], - ), - Err(ExtractionSchemaError::InvalidIdentifier) - ); - assert_eq!( - ExtractionField::new( - "Product Name", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - &[ExtractionSourceChannel::SemanticNode], - ), - Err(ExtractionSchemaError::InvalidIdentifier) - ); - assert_eq!( - ExtractionField::new( - "product name", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - &[ExtractionSourceChannel::SemanticNode], - ), - Err(ExtractionSchemaError::InvalidIdentifier) - ); - assert_eq!( - ExtractionField::new( - "1product_name", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - &[ExtractionSourceChannel::SemanticNode], - ), - Err(ExtractionSchemaError::InvalidIdentifier) - ); - assert_eq!( - ExtractionField::new( - &"a".repeat(MAX_EXTRACTION_IDENTIFIER_BYTES + 1), - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - &[ExtractionSourceChannel::SemanticNode], - ), - Err(ExtractionSchemaError::LimitExceeded) - ); -} - -#[test] -fn field_requires_a_nonempty_duplicate_free_source_channel_set() { - assert_eq!( - ExtractionField::new( - "product_name", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - &[], - ), - Err(ExtractionSchemaError::MissingSourceChannel) - ); - assert_eq!( - ExtractionField::new( - "product_name", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - &[ - ExtractionSourceChannel::SemanticNode, - ExtractionSourceChannel::SemanticNode, - ], - ), - Err(ExtractionSchemaError::DuplicateSourceChannel) - ); -} - -#[test] -fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overflow() --> Result<(), ExtractionSchemaError> { - assert_eq!( - ExtractionSchema::new( - "Product Schema", - vec![field( - "product_name", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - &[ExtractionSourceChannel::SemanticNode], - )?] - ), - Err(ExtractionSchemaError::InvalidIdentifier) - ); - assert_eq!( - ExtractionSchema::new( - &"a".repeat(MAX_EXTRACTION_IDENTIFIER_BYTES + 1), - vec![field( - "product_name", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - &[ExtractionSourceChannel::SemanticNode], - )?], - ), - Err(ExtractionSchemaError::LimitExceeded) - ); - assert_eq!( - ExtractionSchema::new("product-card-v1", vec![]), - Err(ExtractionSchemaError::MissingField) - ); - - let duplicate = field( - "product_name", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - &[ExtractionSourceChannel::SemanticNode], - )?; - let duplicate_again = field( - "product_name", - ExtractionValueType::Text, - ExtractionCardinality::ZeroOrOne, - false, - &[ExtractionSourceChannel::StructuredData], - )?; - assert_eq!( - ExtractionSchema::new("product-card-v1", vec![duplicate, duplicate_again]), - Err(ExtractionSchemaError::DuplicateField) - ); - - let too_many_fields = (0..=MAX_EXTRACTION_FIELD_COUNT) - .map(|index| { - field( - &format!("field_{index}"), - ExtractionValueType::Text, - ExtractionCardinality::ZeroOrOne, - false, - &[ExtractionSourceChannel::SemanticNode], - ) - }) - .collect::, _>>()?; - assert_eq!( - ExtractionSchema::new("product-card-v1", too_many_fields), - Err(ExtractionSchemaError::LimitExceeded) - ); - Ok(()) -} diff --git a/crates/originweave-evidence/tests/extraction_schema_error_contract.rs b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs deleted file mode 100644 index b4897d90f..000000000 --- a/crates/originweave-evidence/tests/extraction_schema_error_contract.rs +++ /dev/null @@ -1,48 +0,0 @@ -use std::error::Error as _; - -use originweave_evidence::ExtractionSchemaError; - -fn assert_standard_error_contract() {} - -#[test] -fn extraction_schema_errors_implement_standard_error_contract() { - assert_standard_error_contract::(); - - for (error, message) in [ - ( - ExtractionSchemaError::InvalidIdentifier, - "invalid extraction schema or field identifier", - ), - ( - ExtractionSchemaError::LimitExceeded, - "extraction schema limit exceeded", - ), - ( - ExtractionSchemaError::InvalidCardinalityRequirement, - "extraction field required flag is incompatible with the declared cardinality", - ), - ( - ExtractionSchemaError::MissingSourceChannel, - "extraction field requires at least one source channel", - ), - ( - ExtractionSchemaError::DuplicateSourceChannel, - "extraction field contains a duplicate source channel", - ), - ( - ExtractionSchemaError::InvalidNormalizationRule, - "extraction normalization rule is incompatible with the field value type", - ), - ( - ExtractionSchemaError::MissingField, - "extraction schema requires at least one field", - ), - ( - ExtractionSchemaError::DuplicateField, - "extraction schema contains a duplicate field identifier", - ), - ] { - assert_eq!(error.to_string(), message); - assert!(error.source().is_none()); - } -} diff --git a/crates/originweave-evidence/tests/extraction_source_channel_set.rs b/crates/originweave-evidence/tests/extraction_source_channel_set.rs deleted file mode 100644 index 1f5070e8a..000000000 --- a/crates/originweave-evidence/tests/extraction_source_channel_set.rs +++ /dev/null @@ -1,40 +0,0 @@ -#![allow(clippy::expect_used)] - -use originweave_evidence::{ - ExtractionCardinality, ExtractionField, ExtractionSourceChannel, ExtractionValueType, -}; - -#[test] -fn equivalent_source_channel_sets_have_canonical_identity() { - let semantic_then_network = ExtractionField::new( - "product_name", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - &[ - ExtractionSourceChannel::SemanticNode, - ExtractionSourceChannel::NetworkResponse, - ], - ) - .expect("reviewed source set must be valid"); - let network_then_semantic = ExtractionField::new( - "product_name", - ExtractionValueType::Text, - ExtractionCardinality::One, - true, - &[ - ExtractionSourceChannel::NetworkResponse, - ExtractionSourceChannel::SemanticNode, - ], - ) - .expect("equivalent reviewed source set must be valid"); - - assert_eq!(semantic_then_network, network_then_semantic); - assert_eq!( - network_then_semantic.source_channels(), - &[ - ExtractionSourceChannel::SemanticNode, - ExtractionSourceChannel::NetworkResponse, - ] - ); -} diff --git a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs deleted file mode 100644 index 6dbf8d713..000000000 --- a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs +++ /dev/null @@ -1,114 +0,0 @@ -use originweave_core::Origin; -use originweave_evidence::{ - SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, - SensitiveAccessOutcome, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, - SensitiveHandleLifecycleEvidenceInput, -}; - -type TestResult = Result<(), String>; - -fn access_evidence( - outcome: SensitiveAccessOutcome, - decision_epoch_seconds: u64, -) -> Result { - let destination = - Origin::parse("https://checkout.example.com").map_err(|error| format!("{error:?}"))?; - SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { - request_id: "request-42".to_owned(), - decision_id: "decision-42".to_owned(), - tenant_id: "tenant-7".to_owned(), - actor_id: "workload-browser-adapter".to_owned(), - task_id: "task-99".to_owned(), - field_ids: vec!["shipping_name".to_owned(), "shipping_address".to_owned()], - purpose_id: "fulfill-shipment".to_owned(), - destination, - classification: SensitiveAccessClass::PersonalData, - outcome, - policy_version: "sensitive-policy-v3".to_owned(), - approval_reference: None, - decision_epoch_seconds, - disclosure_epoch_seconds: None, - retention_deadline_epoch_seconds: Some(decision_epoch_seconds + 3_600), - }) - .map_err(|error| format!("{error:?}")) -} - -fn lifecycle_input( - access_evidence: SensitiveAccessEvidence, - issued_epoch_seconds: u64, -) -> SensitiveHandleLifecycleEvidenceInput { - SensitiveHandleLifecycleEvidenceInput { - access_evidence, - issued_epoch_seconds, - expires_epoch_seconds: issued_epoch_seconds + 300, - maximum_uses: 2, - resolution_count: 0, - revoked_epoch_seconds: None, - } -} - -#[test] -fn lifecycle_identity_retains_complete_opaque_handle_access_receipt() -> TestResult { - let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; - let evidence = - SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access.clone(), 1_720_000_001)) - .map_err(|error| format!("{error:?}"))?; - - assert_eq!(evidence.access_evidence(), &access); - assert_eq!(evidence.request_id(), access.request_id()); - assert_eq!(evidence.decision_id(), access.decision_id()); - assert_eq!(evidence.access_evidence().tenant_id(), "tenant-7"); - assert_eq!(evidence.access_evidence().task_id(), "task-99"); - assert_eq!( - evidence.access_evidence().field_ids(), - ["shipping_name", "shipping_address"] - ); - assert_eq!( - evidence.access_evidence().destination().as_str(), - "https://checkout.example.com" - ); - Ok(()) -} - -#[test] -fn lifecycle_rejects_non_opaque_handle_access_decision() -> TestResult { - let denied = access_evidence(SensitiveAccessOutcome::DenyAccess, 1_720_000_000)?; - - assert_eq!( - SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(denied, 1_720_000_001)), - Err(SensitiveEvidenceError::InvalidLifecycle) - ); - Ok(()) -} - -#[test] -fn lifecycle_rejects_issuance_before_policy_decision() -> TestResult { - let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_100)?; - - assert_eq!( - SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access, 1_720_000_099)), - Err(SensitiveEvidenceError::InvalidLifecycle) - ); - Ok(()) -} - -#[test] -fn lifecycle_expiry_respects_access_retention_deadline() -> TestResult { - let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; - let retention_deadline = access - .retention_deadline_epoch_seconds() - .ok_or_else(|| "fixture must carry a retention deadline".to_owned())?; - - let mut exact_deadline = lifecycle_input(access.clone(), 1_720_000_001); - exact_deadline.expires_epoch_seconds = retention_deadline; - SensitiveHandleLifecycleEvidence::try_from(exact_deadline) - .map_err(|error| format!("{error:?}"))?; - - let mut after_deadline = lifecycle_input(access, 1_720_000_001); - after_deadline.expires_epoch_seconds = retention_deadline + 1; - assert_eq!( - SensitiveHandleLifecycleEvidence::try_from(after_deadline), - Err(SensitiveEvidenceError::InvalidLifecycle) - ); - Ok(()) -} diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs deleted file mode 100644 index 95034cecc..000000000 --- a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs +++ /dev/null @@ -1,142 +0,0 @@ -use originweave_core::Origin; -use originweave_evidence::{ - SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, - SensitiveAccessOutcome, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, - SensitiveHandleLifecycleEvidenceInput, -}; - -type TestResult = Result<(), String>; - -fn valid_access_evidence() -> Result { - let destination = - Origin::parse("https://shipping.example").map_err(|error| format!("{error:?}"))?; - SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { - request_id: "request-42".to_owned(), - decision_id: "decision-42".to_owned(), - tenant_id: "tenant-7".to_owned(), - actor_id: "workload-fulfillment".to_owned(), - task_id: "task-42".to_owned(), - field_ids: vec!["shipping.address".to_owned()], - purpose_id: "fulfill-shipment".to_owned(), - destination, - classification: SensitiveAccessClass::PersonalData, - outcome: SensitiveAccessOutcome::OpaqueHandleOnly, - policy_version: "sensitive-policy-v3".to_owned(), - approval_reference: None, - decision_epoch_seconds: 1_720_000_000, - disclosure_epoch_seconds: None, - retention_deadline_epoch_seconds: Some(1_720_003_600), - }) - .map_err(|error| format!("{error:?}")) -} - -fn valid_input() -> Result { - Ok(SensitiveHandleLifecycleEvidenceInput { - access_evidence: valid_access_evidence()?, - issued_epoch_seconds: 1_720_000_001, - expires_epoch_seconds: 1_720_000_301, - maximum_uses: 2, - resolution_count: 1, - revoked_epoch_seconds: None, - }) -} - -#[test] -fn records_bounded_handle_lifecycle_without_handle_or_secret_material() -> TestResult { - let evidence = SensitiveHandleLifecycleEvidence::try_from(valid_input()?) - .map_err(|error| format!("{error:?}"))?; - - assert_eq!(evidence.request_id(), "request-42"); - assert_eq!(evidence.decision_id(), "decision-42"); - assert_eq!(evidence.issued_epoch_seconds(), 1_720_000_001); - assert_eq!(evidence.expires_epoch_seconds(), 1_720_000_301); - assert_eq!(evidence.maximum_uses(), 2); - assert_eq!(evidence.resolution_count(), 1); - assert_eq!(evidence.revoked_epoch_seconds(), None); - assert!(!evidence.is_revoked()); - - let debug = format!("{evidence:?}"); - assert!(!debug.contains("opaque-handle-token-should-never-be-evidence")); - assert!(!debug.contains("raw-secret-should-never-be-evidence")); - Ok(()) -} - -#[test] -fn records_revocation_time_without_storing_revocation_payloads() -> TestResult { - let mut input = valid_input()?; - input.revoked_epoch_seconds = Some(1_720_000_120); - input.resolution_count = 2; - - let evidence = - SensitiveHandleLifecycleEvidence::try_from(input).map_err(|error| format!("{error:?}"))?; - - assert_eq!(evidence.revoked_epoch_seconds(), Some(1_720_000_120)); - assert!(evidence.is_revoked()); - assert_eq!(evidence.resolution_count(), evidence.maximum_uses()); - Ok(()) -} - -#[test] -fn records_revocation_at_exact_expiry_boundary() -> TestResult { - let mut input = valid_input()?; - input.revoked_epoch_seconds = Some(input.expires_epoch_seconds); - - let evidence = - SensitiveHandleLifecycleEvidence::try_from(input).map_err(|error| format!("{error:?}"))?; - - assert_eq!( - evidence.revoked_epoch_seconds(), - Some(evidence.expires_epoch_seconds()) - ); - assert!(evidence.is_revoked()); - Ok(()) -} - -#[test] -fn rejects_zero_or_non_increasing_handle_lifetime() -> TestResult { - for (issued, expires) in [ - (0, 1_720_000_301), - (1_720_000_301, 1_720_000_301), - (1_720_000_302, 1_720_000_301), - ] { - let mut input = valid_input()?; - input.issued_epoch_seconds = issued; - input.expires_epoch_seconds = expires; - assert_eq!( - SensitiveHandleLifecycleEvidence::try_from(input), - Err(SensitiveEvidenceError::InvalidLifecycle) - ); - } - Ok(()) -} - -#[test] -fn rejects_zero_use_limit_or_resolution_count_above_limit() -> TestResult { - let mut zero_limit = valid_input()?; - zero_limit.maximum_uses = 0; - assert_eq!( - SensitiveHandleLifecycleEvidence::try_from(zero_limit), - Err(SensitiveEvidenceError::InvalidLifecycle) - ); - - let mut overused = valid_input()?; - overused.resolution_count = overused.maximum_uses + 1; - assert_eq!( - SensitiveHandleLifecycleEvidence::try_from(overused), - Err(SensitiveEvidenceError::InvalidLifecycle) - ); - Ok(()) -} - -#[test] -fn rejects_revocation_before_issue_or_after_expiry() -> TestResult { - for revoked in [1_720_000_000, 1_720_000_302] { - let mut input = valid_input()?; - input.revoked_epoch_seconds = Some(revoked); - assert_eq!( - SensitiveHandleLifecycleEvidence::try_from(input), - Err(SensitiveEvidenceError::InvalidLifecycle) - ); - } - Ok(()) -} diff --git a/crates/originweave-network/src/webdriver_bidi_connection.rs b/crates/originweave-network/src/webdriver_bidi_connection.rs new file mode 100644 index 000000000..5d39bb5e3 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_connection.rs @@ -0,0 +1,254 @@ +use std::{ + io, + net::{SocketAddr, TcpStream}, + time::Duration, +}; + +use originweave_core::{VerifiedWebDriverBiDiSocketPeer, WebDriverBiDiWebSocketConnectTarget}; + +use crate::connection::{MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS}; + +mod error; + +pub use error::WebDriverBiDiTcpConnectionError; + +#[cfg(test)] +mod tests; + +fn is_retryable_connect_error(kind: io::ErrorKind) -> bool { + matches!( + kind, + io::ErrorKind::TimedOut + | io::ErrorKind::ConnectionRefused + | io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::Interrupted + ) +} + +/// Single-use authority to open one exact WebDriver BiDi loopback TCP destination. +/// +/// The plan consumes a session-correlated, no-DNS [`WebDriverBiDiWebSocketConnectTarget`] +/// produced by `originweave-core`. It applies the same bounded per-attempt timeout and retry +/// ceilings as the general direct-network connector, opens only the exact [`SocketAddr`] carried by +/// that target, and does not expose the stream until the operating system's observed peer has been +/// verified by the consumed target. +/// +/// This boundary performs no DNS lookup, proxy or PAC routing, Chromium/ChromeDriver process +/// authentication, TLS negotiation, WebSocket upgrade, BiDi framing, browser policy decision, or +/// Agent-authority grant. +#[derive(Debug)] +pub struct WebDriverBiDiTcpConnectionPlan { + target: WebDriverBiDiWebSocketConnectTarget, + connect_timeout: Duration, + maximum_attempts: u8, +} + +impl WebDriverBiDiTcpConnectionPlan { + /// Validate one bounded exact-loopback connection plan without performing network I/O. + pub fn new( + target: WebDriverBiDiWebSocketConnectTarget, + connect_timeout: Duration, + maximum_attempts: u8, + ) -> Result { + if connect_timeout.is_zero() || connect_timeout > MAX_CONNECT_TIMEOUT { + return Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { + connect_timeout, + maximum_timeout: MAX_CONNECT_TIMEOUT, + }); + } + if maximum_attempts == 0 || maximum_attempts > MAX_CONNECTION_ATTEMPTS { + return Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { + attempt_count: maximum_attempts, + maximum_attempts: MAX_CONNECTION_ATTEMPTS, + }); + } + + Ok(Self { + target, + connect_timeout, + maximum_attempts, + }) + } + + /// Open the exact approved loopback socket and expose it only after peer verification. + /// + /// Retry is limited to transport errors that can occur transiently while a local browser driver + /// listener is becoming ready. Peer-inspection and peer-mismatch failures are integrity failures + /// and therefore fail closed without retry or fallback. + pub fn connect(self) -> Result { + self.connect_with(&SystemWebDriverBiDiConnector) + } + + fn connect_with( + self, + connector: &dyn WebDriverBiDiSocketConnector, + ) -> Result { + let socket_address = self.target.socket_addr(); + let connect_timeout = self.connect_timeout; + let maximum_attempts = self.maximum_attempts; + let target = self.target; + let mut attempt_number = 1; + + loop { + match connector.connect_timeout(&socket_address, connect_timeout) { + Ok(stream) => { + let observed_peer = connector.peer_addr(&stream).map_err(|source| { + WebDriverBiDiTcpConnectionError::PeerInspectionFailed { + socket_address, + attempt_number, + source, + } + })?; + let verified_peer = + target + .verify_connected_peer(observed_peer) + .map_err(|source| WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number, + source, + })?; + return Ok(WebDriverBiDiTcpConnection { + stream, + verified_peer, + attempt_number, + connect_timeout, + }); + } + Err(source) + if is_retryable_connect_error(source.kind()) + && attempt_number < maximum_attempts => + { + attempt_number += 1; + } + Err(source) => { + if source.kind() == io::ErrorKind::TimedOut { + return Err(WebDriverBiDiTcpConnectionError::ConnectionTimedOut { + socket_address, + attempt_count: attempt_number, + connect_timeout, + source, + }); + } + return Err(WebDriverBiDiTcpConnectionError::ConnectionFailed { + socket_address, + attempt_count: attempt_number, + source, + }); + } + } + } + } +} + +trait WebDriverBiDiSocketConnector { + fn connect_timeout( + &self, + socket_address: &SocketAddr, + timeout: Duration, + ) -> io::Result; + + fn peer_addr(&self, stream: &TcpStream) -> io::Result; +} + +struct SystemWebDriverBiDiConnector; + +impl WebDriverBiDiSocketConnector for SystemWebDriverBiDiConnector { + fn connect_timeout( + &self, + socket_address: &SocketAddr, + timeout: Duration, + ) -> io::Result { + TcpStream::connect_timeout(socket_address, timeout) + } + + fn peer_addr(&self, stream: &TcpStream) -> io::Result { + stream.peer_addr() + } +} + +/// Established WebDriver BiDi TCP stream whose observed peer matched the approved target exactly. +/// +/// This wrapper proves only exact transport-destination equality for one bounded connection. The +/// caller must still establish any required TLS channel, complete a WebSocket handshake, bind the +/// transport to the expected browser process/session, and pass separate action-policy checks. +#[derive(Debug)] +pub struct WebDriverBiDiTcpConnection { + stream: TcpStream, + verified_peer: VerifiedWebDriverBiDiSocketPeer, + attempt_number: u8, + connect_timeout: Duration, +} + +impl WebDriverBiDiTcpConnection { + /// Borrow the verified TCP stream. + #[must_use] + pub const fn stream(&self) -> &TcpStream { + &self.stream + } + + /// Borrow the session-correlated exact peer evidence consumed by this connection. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + &self.verified_peer + } + + /// Return the one-based bounded attempt on which the connection succeeded. + #[must_use] + pub const fn attempt_number(&self) -> u8 { + self.attempt_number + } + + /// Return the per-attempt timeout applied while establishing this connection. + #[must_use] + pub const fn connect_timeout(&self) -> Duration { + self.connect_timeout + } + + /// Consume the wrapper into the original verified stream and credential-free transport evidence. + /// + /// This handoff does not clone the socket or create reusable connection authority. The returned + /// evidence records only the already-verified peer plus bounded connection-attempt metadata; it + /// does not authenticate a browser process, establish TLS, complete WebSocket framing, or grant + /// browser or Agent authority. + #[must_use] + pub fn into_parts(self) -> (TcpStream, WebDriverBiDiTcpConnectionEvidence) { + let evidence = WebDriverBiDiTcpConnectionEvidence { + verified_peer: self.verified_peer, + attempt_number: self.attempt_number, + connect_timeout: self.connect_timeout, + }; + (self.stream, evidence) + } +} + +/// Credential-free evidence retained when a verified WebDriver BiDi TCP stream is consumed. +/// +/// This value records exact peer/session/TLS-requirement metadata inherited from the consumed +/// no-DNS target together with the successful bounded attempt and per-attempt timeout. It is +/// transport evidence only and grants no process, TLS, WebSocket, browser-action, or Agent authority. +#[derive(Debug)] +pub struct WebDriverBiDiTcpConnectionEvidence { + verified_peer: VerifiedWebDriverBiDiSocketPeer, + attempt_number: u8, + connect_timeout: Duration, +} + +impl WebDriverBiDiTcpConnectionEvidence { + /// Borrow the exact session-correlated peer verified before stream exposure. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + &self.verified_peer + } + + /// Return the one-based bounded attempt on which the connection succeeded. + #[must_use] + pub const fn attempt_number(&self) -> u8 { + self.attempt_number + } + + /// Return the per-attempt timeout applied while establishing the connection. + #[must_use] + pub const fn connect_timeout(&self) -> Duration { + self.connect_timeout + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_connection/error.rs b/crates/originweave-network/src/webdriver_bidi_connection/error.rs new file mode 100644 index 000000000..226cd1d2b --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_connection/error.rs @@ -0,0 +1,134 @@ +use std::{fmt, io, net::SocketAddr, time::Duration}; + +use originweave_core::WebDriverBiDiSocketPeerVerificationError; + +/// Deterministic failures while establishing one bounded WebDriver BiDi TCP transport. +#[derive(Debug)] +pub enum WebDriverBiDiTcpConnectionError { + /// The requested timeout was zero or exceeded [`crate::MAX_CONNECT_TIMEOUT`]. + InvalidConnectTimeout { + /// The rejected timeout. + connect_timeout: Duration, + /// The largest accepted per-attempt timeout. + maximum_timeout: Duration, + }, + /// The requested attempt count was outside `1..=MAX_CONNECTION_ATTEMPTS`. + InvalidAttemptCount { + /// The rejected attempt count. + attempt_count: u8, + /// The largest accepted attempt count. + maximum_attempts: u8, + }, + /// The final bounded connection attempt timed out. + ConnectionTimedOut { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// Number of attempts completed before failure. + attempt_count: u8, + /// Per-attempt timeout used by the plan. + connect_timeout: Duration, + /// Final operating-system timeout error. + source: io::Error, + }, + /// The final bounded connection attempt failed without a timeout. + ConnectionFailed { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// Number of attempts completed before failure. + attempt_count: u8, + /// Final operating-system connection error. + source: io::Error, + }, + /// The established stream did not reveal an operating-system peer address. + PeerInspectionFailed { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// One-based attempt that established the stream. + attempt_number: u8, + /// Operating-system peer-inspection error. + source: io::Error, + }, + /// The established stream reported a peer other than the exact approved BiDi target. + PeerMismatch { + /// One-based attempt that established the stream. + attempt_number: u8, + /// Typed core peer-verification failure preserving expected and actual socket addresses. + source: WebDriverBiDiSocketPeerVerificationError, + }, +} + +impl WebDriverBiDiTcpConnectionError { + /// Return the number of transport attempts associated with this failure, when applicable. + #[must_use] + pub const fn attempt_count(&self) -> Option { + match self { + Self::ConnectionTimedOut { attempt_count, .. } + | Self::ConnectionFailed { attempt_count, .. } => Some(*attempt_count), + Self::PeerInspectionFailed { attempt_number, .. } + | Self::PeerMismatch { attempt_number, .. } => Some(*attempt_number), + Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } => None, + } + } +} + +impl fmt::Display for WebDriverBiDiTcpConnectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidConnectTimeout { + connect_timeout, + maximum_timeout, + } => write!( + formatter, + "WebDriver BiDi connect timeout {connect_timeout:?} is outside 1ns..={maximum_timeout:?}", + ), + Self::InvalidAttemptCount { + attempt_count, + maximum_attempts, + } => write!( + formatter, + "WebDriver BiDi connection attempt count {attempt_count} is outside 1..={maximum_attempts}", + ), + Self::ConnectionTimedOut { + socket_address, + attempt_count, + connect_timeout, + .. + } => write!( + formatter, + "WebDriver BiDi TCP connection to {socket_address} timed out after {attempt_count} attempts with per-attempt timeout {connect_timeout:?}", + ), + Self::ConnectionFailed { + socket_address, + attempt_count, + .. + } => write!( + formatter, + "WebDriver BiDi TCP connection to {socket_address} failed after {attempt_count} attempts", + ), + Self::PeerInspectionFailed { + socket_address, + attempt_number, + .. + } => write!( + formatter, + "WebDriver BiDi TCP peer inspection failed for {socket_address} on attempt {attempt_number}", + ), + Self::PeerMismatch { attempt_number, .. } => write!( + formatter, + "WebDriver BiDi TCP peer did not match the approved target on attempt {attempt_number}", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiTcpConnectionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ConnectionTimedOut { source, .. } + | Self::ConnectionFailed { source, .. } + | Self::PeerInspectionFailed { source, .. } => Some(source), + Self::PeerMismatch { source, .. } => Some(source), + Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } => None, + } + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_connection/tests.rs b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs new file mode 100644 index 000000000..e2d287ca1 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs @@ -0,0 +1,361 @@ +#![allow(clippy::expect_used)] + +use std::{ + cell::{Cell, RefCell}, + collections::VecDeque, + error::Error, + io, + net::{SocketAddr, TcpListener, TcpStream}, + time::Duration, +}; + +use originweave_core::{WebDriverBiDiWebSocketConnectTarget, WebDriverBiDiWebSocketEndpoint}; + +use super::{ + WebDriverBiDiSocketConnector, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan, + is_retryable_connect_error, +}; +use crate::connection::{MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn socket_address() -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], 9515)) +} + +enum ConnectOutcome { + Success(TcpStream), + Error(io::ErrorKind), +} + +enum PeerOutcome { + Address(SocketAddr), + Error(io::ErrorKind), +} + +struct FakeConnector { + connect_outcomes: RefCell>, + peer_outcomes: RefCell>, + connect_calls: Cell, + peer_calls: Cell, +} + +impl FakeConnector { + fn new(connect_outcomes: Vec, peer_outcomes: Vec) -> Self { + Self { + connect_outcomes: RefCell::new(connect_outcomes.into()), + peer_outcomes: RefCell::new(peer_outcomes.into()), + connect_calls: Cell::new(0), + peer_calls: Cell::new(0), + } + } +} + +impl WebDriverBiDiSocketConnector for FakeConnector { + fn connect_timeout( + &self, + _socket_address: &SocketAddr, + _timeout: Duration, + ) -> io::Result { + self.connect_calls.set(self.connect_calls.get() + 1); + match self + .connect_outcomes + .borrow_mut() + .pop_front() + .expect("test must provide a connection outcome") + { + ConnectOutcome::Success(stream) => Ok(stream), + ConnectOutcome::Error(kind) => Err(io::Error::from(kind)), + } + } + + fn peer_addr(&self, _stream: &TcpStream) -> io::Result { + self.peer_calls.set(self.peer_calls.get() + 1); + match self + .peer_outcomes + .borrow_mut() + .pop_front() + .expect("test must provide a peer outcome") + { + PeerOutcome::Address(address) => Ok(address), + PeerOutcome::Error(kind) => Err(io::Error::from(kind)), + } + } +} + +fn loopback_stream() -> TcpStream { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback listener"); + let address = listener + .local_addr() + .expect("read loopback listener address"); + let client = TcpStream::connect(address).expect("connect loopback client"); + let (server, _) = listener.accept().expect("accept loopback client"); + drop(server); + client +} + +fn connect_target(secure: bool) -> WebDriverBiDiWebSocketConnectTarget { + let scheme = if secure { "wss" } else { "ws" }; + let endpoint = format!("{scheme}://127.0.0.1:9515/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint).expect("admit endpoint"); + let correlated = admitted + .correlate_session_id(SESSION_ID) + .expect("correlate endpoint"); + correlated + .into_explicit_connect_target() + .expect("derive explicit connect target") +} + +fn plan(maximum_attempts: u8) -> WebDriverBiDiTcpConnectionPlan { + WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + Duration::from_millis(250), + maximum_attempts, + ) + .expect("valid test plan") +} + +#[test] +fn validates_timeout_and_attempt_bounds_before_io() { + let zero_timeout = + WebDriverBiDiTcpConnectionPlan::new(connect_target(false), Duration::ZERO, 1); + assert!(matches!( + zero_timeout, + Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) + )); + + let excessive_timeout = WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + MAX_CONNECT_TIMEOUT + Duration::from_nanos(1), + 1, + ); + assert!(matches!( + excessive_timeout, + Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) + )); + + let zero_attempts = + WebDriverBiDiTcpConnectionPlan::new(connect_target(false), Duration::from_millis(250), 0); + assert!(matches!( + zero_attempts, + Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) + )); + + let excessive_attempts = WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + Duration::from_millis(250), + MAX_CONNECTION_ATTEMPTS + 1, + ); + assert!(matches!( + excessive_attempts, + Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) + )); +} + +#[test] +fn verified_peer_is_required_before_stream_exposure() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Address(socket_address())], + ); + let connection = + WebDriverBiDiTcpConnectionPlan::new(connect_target(true), Duration::from_millis(250), 1) + .expect("valid plan") + .connect_with(&connector) + .expect("verified connection"); + + assert!(connection.stream().peer_addr().is_ok()); + assert_eq!(connection.verified_peer().socket_addr(), socket_address()); + assert!(connection.verified_peer().requires_tls()); + assert_eq!(connection.verified_peer().session_id(), SESSION_ID); + assert_eq!(connection.attempt_number(), 1); + assert_eq!(connection.connect_timeout(), Duration::from_millis(250)); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); +} + +#[test] +fn all_recoverable_connect_kinds_can_retry_once() { + for kind in [ + io::ErrorKind::TimedOut, + io::ErrorKind::ConnectionRefused, + io::ErrorKind::ConnectionReset, + io::ErrorKind::ConnectionAborted, + io::ErrorKind::Interrupted, + ] { + assert!(is_retryable_connect_error(kind)); + let connector = FakeConnector::new( + vec![ + ConnectOutcome::Error(kind), + ConnectOutcome::Success(loopback_stream()), + ], + vec![PeerOutcome::Address(socket_address())], + ); + let connection = plan(2) + .connect_with(&connector) + .expect("second bounded attempt succeeds"); + assert_eq!(connection.attempt_number(), 2); + assert_eq!(connector.connect_calls.get(), 2); + assert_eq!(connector.peer_calls.get(), 1); + } + assert!(!is_retryable_connect_error(io::ErrorKind::PermissionDenied)); +} + +#[test] +fn final_timeout_preserves_source_and_attempt_count() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::TimedOut)], + Vec::new(), + ); + let error = plan(1) + .connect_with(&connector) + .expect_err("timeout must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionTimedOut { + attempt_count: 1, + .. + } + )); + assert!(error.source().is_some()); + assert_eq!(error.attempt_count(), Some(1)); +} + +#[test] +fn exhausted_retryable_non_timeout_error_is_connection_failure() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::ConnectionRefused)], + Vec::new(), + ); + let error = plan(1) + .connect_with(&connector) + .expect_err("refusal must fail after the bounded final attempt"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + attempt_count: 1, + .. + } + )); + assert!(error.source().is_some()); +} + +#[test] +fn non_retryable_connection_error_fails_without_retry() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::PermissionDenied)], + Vec::new(), + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("permission failure must not retry"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + attempt_count: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); +} + +#[test] +fn peer_inspection_failure_is_not_retried() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Error(io::ErrorKind::NotConnected)], + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("peer inspection failure must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::PeerInspectionFailed { + attempt_number: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); + assert!(error.source().is_some()); +} + +#[test] +fn peer_mismatch_is_not_retried_or_converted_to_success() { + let wrong_peer = SocketAddr::from(([127, 0, 0, 1], 9516)); + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Address(wrong_peer)], + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("peer mismatch must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); + assert!(error.source().is_some()); +} + +#[test] +fn error_display_source_and_attempt_contracts_cover_every_variant() { + let mismatch = connect_target(false) + .verify_connected_peer(SocketAddr::from(([127, 0, 0, 1], 9516))) + .expect_err("wrong peer must fail"); + let errors = [ + WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { + connect_timeout: Duration::ZERO, + maximum_timeout: MAX_CONNECT_TIMEOUT, + }, + WebDriverBiDiTcpConnectionError::InvalidAttemptCount { + attempt_count: 0, + maximum_attempts: MAX_CONNECTION_ATTEMPTS, + }, + WebDriverBiDiTcpConnectionError::ConnectionTimedOut { + socket_address: socket_address(), + attempt_count: 2, + connect_timeout: Duration::from_millis(250), + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + socket_address: socket_address(), + attempt_count: 3, + source: io::Error::from(io::ErrorKind::ConnectionRefused), + }, + WebDriverBiDiTcpConnectionError::PeerInspectionFailed { + socket_address: socket_address(), + attempt_number: 1, + source: io::Error::from(io::ErrorKind::NotConnected), + }, + WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number: 1, + source: mismatch, + }, + ]; + + let messages: Vec = errors.iter().map(ToString::to_string).collect(); + assert!(messages[0].contains("outside 1ns")); + assert!(messages[1].contains("attempt count 0")); + assert!(messages[2].contains("timed out after 2 attempts")); + assert!(messages[3].contains("failed after 3 attempts")); + assert!(messages[4].contains("peer inspection failed")); + assert!(messages[5].contains("did not match the approved target")); + + assert_eq!(errors[0].attempt_count(), None); + assert_eq!(errors[1].attempt_count(), None); + assert_eq!(errors[2].attempt_count(), Some(2)); + assert_eq!(errors[3].attempt_count(), Some(3)); + assert_eq!(errors[4].attempt_count(), Some(1)); + assert_eq!(errors[5].attempt_count(), Some(1)); + assert!(errors[0].source().is_none()); + assert!(errors[1].source().is_none()); + assert!(errors[2].source().is_some()); + assert!(errors[3].source().is_some()); + assert!(errors[4].source().is_some()); + assert!(errors[5].source().is_some()); +} diff --git a/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs b/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs new file mode 100644 index 000000000..fac15b6a5 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs @@ -0,0 +1,94 @@ +use std::{net::TcpListener, thread, time::Duration}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn connect_target(endpoint: &str) -> originweave_core::WebDriverBiDiWebSocketConnectTarget { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted literal loopback target") + }; + target +} + +#[test] +fn exact_loopback_target_opens_one_verified_bidi_tcp_stream() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + + let server = thread::spawn(move || listener.accept().map(|_| ())); + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let target = connect_target(&endpoint); + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let connection = plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + return; + }; + + assert_eq!(connection.verified_peer().socket_addr(), local_addr); + assert!(!connection.verified_peer().requires_tls()); + assert_eq!(connection.verified_peer().session_id(), SESSION_ID); + assert_eq!(connection.attempt_number(), 1); + assert_eq!(connection.connect_timeout(), Duration::from_secs(1)); + + let (stream, evidence) = connection.into_parts(); + assert_eq!(stream.peer_addr().ok(), Some(local_addr)); + assert_eq!(evidence.verified_peer().socket_addr(), local_addr); + assert!(!evidence.verified_peer().requires_tls()); + assert_eq!(evidence.verified_peer().session_id(), SESSION_ID); + assert_eq!(evidence.attempt_number(), 1); + assert_eq!(evidence.connect_timeout(), Duration::from_secs(1)); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + +#[test] +fn bidi_tcp_plan_rejects_invalid_retry_settings_before_io() { + let endpoint = format!("wss://127.0.0.1:9443/session/{SESSION_ID}"); + let zero_timeout = + WebDriverBiDiTcpConnectionPlan::new(connect_target(&endpoint), Duration::ZERO, 1); + assert!(matches!( + zero_timeout, + Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) + )); + + let zero_attempts = + WebDriverBiDiTcpConnectionPlan::new(connect_target(&endpoint), Duration::from_secs(1), 0); + assert!(matches!( + zero_attempts, + Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) + )); +} diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index dbfb3c16d..243ae8ce7 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -15,7 +15,6 @@ pub use sensitive_data::{ evaluate_handle_use, }; -use originweave_core::mcp::ValidatedMcpToolCall; use originweave_core::{ ActionRequest, ApprovalEvidence, ApprovalScope, Capability, ExecutionPurpose, InstructionSource, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, SessionMode, @@ -41,8 +40,6 @@ pub enum DenialReason { ModePurposeMismatch, /// Page or document content attempted to become a trusted instruction. UntrustedInstructionSource, - /// The validated MCP route resolved to a different action than the policy request. - McpActionMismatch, /// The session lacks the exact capability required by the action. MissingCapability(Capability), /// The target origin is outside the session's read grant. @@ -69,23 +66,6 @@ pub enum DenialReason { ApprovalScopeMismatch, } -/// Evaluate a policy request only when it matches an already validated MCP route. -/// -/// Matching routing metadata grants no authority. Once route and request action agree, the request -/// still passes through the existing action policy unchanged. -#[must_use] -pub fn evaluate_mcp( - call: &ValidatedMcpToolCall, - request: &ActionRequest, - context: &PolicyContext, -) -> Decision { - if call.action_kind() != request.action() { - return Decision::Deny(DenialReason::McpActionMismatch); - } - - evaluate(request, context) -} - /// Evaluate a typed browser action against one explicit policy context. #[must_use] pub fn evaluate(request: &ActionRequest, context: &PolicyContext) -> Decision { diff --git a/crates/originweave-policy/tests/extension_mutation_isolation.rs b/crates/originweave-policy/tests/extension_mutation_isolation.rs deleted file mode 100644 index 48d7936e1..000000000 --- a/crates/originweave-policy/tests/extension_mutation_isolation.rs +++ /dev/null @@ -1,343 +0,0 @@ -#![allow(clippy::expect_used)] - -//! Keep extension proposal-grant evaluation separate from ordinary action policy. -//! -//! OriginWeave does not yet implement an adapter that converts an extension proposal into an -//! [`ActionRequest`]. These regressions therefore prove two independent fail-closed boundaries: -//! the exact extension/session/context/origin/unexpired grant permits only `ProposeTypedAction`, -//! while an ordinary user-sourced action request remains subject to the core policy decision -//! shown in each test. - -use std::collections::BTreeSet; - -use originweave_core::{ - ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, - BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, - ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, - InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, - evaluate_extension_access, -}; -use originweave_policy::{Decision, DenialReason, evaluate}; - -const VALID_INTENT: &str = - "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -const EXTENSION_ORIGIN: &str = "https://extension.example"; -const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; -const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; - -fn extension_id() -> ExtensionId { - ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") -} - -fn browser_session() -> BrowserSessionId { - BrowserSessionId::new(17).expect("nonzero browser session") -} - -fn browsing_context() -> BrowsingContextId { - BrowsingContextId::new(23).expect("nonzero browsing context") -} - -fn origin(value: &str) -> Origin { - Origin::parse(value).expect("valid test origin") -} - -fn intent() -> ActionIntentDigest { - ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") -} - -fn action_proposal_grant() -> ExtensionAgentGrant { - ExtensionAgentGrant::new( - extension_id(), - browser_session(), - browsing_context(), - origin(EXTENSION_ORIGIN), - UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, - [ExtensionAgentCapability::ProposeTypedAction], - ) -} - -fn assert_proposal_grant_is_independently_allowed(grant: &ExtensionAgentGrant) { - let request = ExtensionAccessRequest::new( - extension_id(), - browser_session(), - browsing_context(), - origin(EXTENSION_ORIGIN), - UNEXPIRED_NOW_EPOCH_SECONDS, - ExtensionAgentCapability::ProposeTypedAction, - ); - assert_eq!( - evaluate_extension_access(&request, Some(grant)), - ExtensionAccessDecision::Allow - ); -} - -#[test] -fn extension_proposal_grant_is_independent_of_cross_origin_mutation_policy() { - let grant = action_proposal_grant(); - assert_proposal_grant_is_independently_allowed(&grant); - - let source = origin("https://source.example"); - let target = origin("https://target.example"); - let context = PolicyContext::new( - SessionMode::AgentTask, - ExecutionPurpose::UserDelegatedTask, - BTreeSet::from([Capability::Submit]), - BTreeSet::from([source.clone(), target.clone()]), - BTreeSet::from([target.clone()]), - RobotsDecision::Allowed, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::Submit, - source, - target, - InstructionSource::User, - SecretDelivery::None, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::CrossOriginMutation) - ); -} - -#[test] -fn extension_proposal_grant_is_independent_of_write_origin_policy() { - let grant = action_proposal_grant(); - assert_proposal_grant_is_independently_allowed(&grant); - - let site = origin("https://app.example"); - let context = PolicyContext::new( - SessionMode::AgentTask, - ExecutionPurpose::UserDelegatedTask, - BTreeSet::from([Capability::Submit]), - BTreeSet::from([site.clone()]), - BTreeSet::new(), - RobotsDecision::Allowed, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::Submit, - site.clone(), - site, - InstructionSource::User, - SecretDelivery::None, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::OriginNotWritable) - ); -} - -#[test] -fn extension_proposal_grant_is_independent_of_crawler_mutation_policy() { - let grant = action_proposal_grant(); - assert_proposal_grant_is_independently_allowed(&grant); - - let site = origin("https://public.example"); - let context = PolicyContext::new( - SessionMode::Crawler, - ExecutionPurpose::PublicCrawl, - BTreeSet::from([Capability::Submit]), - BTreeSet::from([site.clone()]), - BTreeSet::from([site.clone()]), - RobotsDecision::Allowed, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::Submit, - site.clone(), - site, - InstructionSource::User, - SecretDelivery::None, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::CrawlerMutation) - ); -} - -#[test] -fn extension_proposal_grant_is_independent_of_mode_purpose_policy() { - let grant = action_proposal_grant(); - assert_proposal_grant_is_independently_allowed(&grant); - - let site = origin("https://public.example"); - let context = PolicyContext::new( - SessionMode::AgentTask, - ExecutionPurpose::PublicCrawl, - BTreeSet::from([Capability::Observe]), - BTreeSet::from([site.clone()]), - BTreeSet::new(), - RobotsDecision::Allowed, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::Observe, - site.clone(), - site, - InstructionSource::User, - SecretDelivery::None, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::ModePurposeMismatch) - ); -} - -#[test] -fn extension_proposal_grant_is_independent_of_disallowed_robots_policy() { - let grant = action_proposal_grant(); - assert_proposal_grant_is_independently_allowed(&grant); - - let site = origin("https://public.example"); - let context = PolicyContext::new( - SessionMode::Crawler, - ExecutionPurpose::PublicCrawl, - BTreeSet::from([Capability::Observe]), - BTreeSet::from([site.clone()]), - BTreeSet::new(), - RobotsDecision::Disallowed, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::Observe, - site.clone(), - site, - InstructionSource::User, - SecretDelivery::None, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::RobotsDisallowed) - ); -} - -#[test] -fn extension_proposal_grant_is_independent_of_unknown_robots_policy() { - let grant = action_proposal_grant(); - assert_proposal_grant_is_independently_allowed(&grant); - - let site = origin("https://public.example"); - let context = PolicyContext::new( - SessionMode::Crawler, - ExecutionPurpose::PublicCrawl, - BTreeSet::from([Capability::Observe]), - BTreeSet::from([site.clone()]), - BTreeSet::new(), - RobotsDecision::Unknown, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::Observe, - site.clone(), - site, - InstructionSource::User, - SecretDelivery::None, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::RobotsUnknown) - ); -} - -#[test] -fn extension_proposal_grant_is_independent_of_missing_robots_policy() { - let grant = action_proposal_grant(); - assert_proposal_grant_is_independently_allowed(&grant); - - let site = origin("https://public.example"); - let context = PolicyContext::new( - SessionMode::Crawler, - ExecutionPurpose::PublicCrawl, - BTreeSet::from([Capability::Observe]), - BTreeSet::from([site.clone()]), - BTreeSet::new(), - RobotsDecision::NotApplicable, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::Observe, - site.clone(), - site, - InstructionSource::User, - SecretDelivery::None, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::RobotsNotApplicable) - ); -} - -#[test] -fn extension_proposal_grant_is_independent_of_non_delegable_r5_policy() { - let grant = action_proposal_grant(); - assert_proposal_grant_is_independently_allowed(&grant); - - let site = origin("https://consent.example"); - let context = PolicyContext::new( - SessionMode::AgentTask, - ExecutionPurpose::UserDelegatedTask, - BTreeSet::from([Capability::LegalConsent]), - BTreeSet::from([site.clone()]), - BTreeSet::from([site.clone()]), - RobotsDecision::Allowed, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::LegalConsent, - site.clone(), - site, - InstructionSource::User, - SecretDelivery::None, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::ForbiddenRisk) - ); -} - -#[test] -fn extension_proposal_grant_is_independent_of_human_mode_policy() { - let grant = action_proposal_grant(); - assert_proposal_grant_is_independently_allowed(&grant); - - let site = origin("https://human.example"); - let context = PolicyContext::new( - SessionMode::Human, - ExecutionPurpose::UserDelegatedTask, - BTreeSet::from([Capability::Navigate]), - BTreeSet::from([site.clone()]), - BTreeSet::new(), - RobotsDecision::NotApplicable, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::Navigate, - site.clone(), - site, - InstructionSource::User, - SecretDelivery::None, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::HumanModeNotAgentControlled) - ); -} diff --git a/crates/originweave-policy/tests/extension_policy_isolation.rs b/crates/originweave-policy/tests/extension_policy_isolation.rs deleted file mode 100644 index f32d8733c..000000000 --- a/crates/originweave-policy/tests/extension_policy_isolation.rs +++ /dev/null @@ -1,215 +0,0 @@ -#![allow(clippy::expect_used)] - -use std::collections::BTreeSet; - -use originweave_core::{ - ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, - BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, - ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, - InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, - evaluate_extension_access, -}; -use originweave_policy::{Decision, DenialReason, evaluate}; - -const VALID_INTENT: &str = - "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -const EXTENSION_ORIGIN: &str = "https://extension.example"; -const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; -const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; - -fn extension_id() -> ExtensionId { - ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") -} - -fn browser_session() -> BrowserSessionId { - BrowserSessionId::new(7).expect("nonzero browser session") -} - -fn browsing_context() -> BrowsingContextId { - BrowsingContextId::new(11).expect("nonzero browsing context") -} - -fn origin(value: &str) -> Origin { - Origin::parse(value).expect("valid test origin") -} - -fn intent() -> ActionIntentDigest { - ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") -} - -fn action_proposal_grant() -> ExtensionAgentGrant { - ExtensionAgentGrant::new( - extension_id(), - browser_session(), - browsing_context(), - origin(EXTENSION_ORIGIN), - UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, - [ExtensionAgentCapability::ProposeTypedAction], - ) -} - -fn assert_extension_can_only_propose(grant: &ExtensionAgentGrant) { - let request = ExtensionAccessRequest::new( - extension_id(), - browser_session(), - browsing_context(), - origin(EXTENSION_ORIGIN), - UNEXPIRED_NOW_EPOCH_SECONDS, - ExtensionAgentCapability::ProposeTypedAction, - ); - assert_eq!( - evaluate_extension_access(&request, Some(grant)), - ExtensionAccessDecision::Allow - ); -} - -#[test] -fn explicit_extension_grant_does_not_widen_agent_origin_authority() { - let grant = action_proposal_grant(); - assert_extension_can_only_propose(&grant); - - let allowed = origin("https://app.example"); - let forbidden = origin("https://outside.example"); - let context = PolicyContext::new( - SessionMode::AgentTask, - ExecutionPurpose::UserDelegatedTask, - BTreeSet::from([Capability::Navigate]), - BTreeSet::from([allowed.clone()]), - BTreeSet::new(), - RobotsDecision::Allowed, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::Navigate, - allowed, - forbidden, - InstructionSource::User, - SecretDelivery::None, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::OriginNotReadable) - ); -} - -#[test] -fn explicit_extension_grant_does_not_supply_agent_action_capability() { - let grant = action_proposal_grant(); - assert_extension_can_only_propose(&grant); - - let site = origin("https://app.example"); - let context = PolicyContext::new( - SessionMode::AgentTask, - ExecutionPurpose::UserDelegatedTask, - BTreeSet::from([Capability::Observe]), - BTreeSet::from([site.clone()]), - BTreeSet::new(), - RobotsDecision::Allowed, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::Navigate, - site.clone(), - site, - InstructionSource::User, - SecretDelivery::None, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::MissingCapability(Capability::Navigate)) - ); -} - -#[test] -fn untrusted_extension_content_cannot_become_a_policy_instruction() { - let grant = action_proposal_grant(); - assert_extension_can_only_propose(&grant); - - let site = origin("https://app.example"); - let context = PolicyContext::new( - SessionMode::AgentTask, - ExecutionPurpose::UserDelegatedTask, - BTreeSet::from([Capability::Navigate]), - BTreeSet::from([site.clone()]), - BTreeSet::new(), - RobotsDecision::Allowed, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::Navigate, - site.clone(), - site, - InstructionSource::WebContent, - SecretDelivery::None, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::UntrustedInstructionSource) - ); -} - -#[test] -fn explicit_extension_grant_cannot_turn_raw_secret_delivery_into_a_fill_capability() { - let grant = action_proposal_grant(); - assert_extension_can_only_propose(&grant); - - let site = origin("https://app.example"); - let context = PolicyContext::new( - SessionMode::AgentTask, - ExecutionPurpose::UserDelegatedTask, - BTreeSet::from([Capability::FillSecret]), - BTreeSet::from([site.clone()]), - BTreeSet::from([site.clone()]), - RobotsDecision::Allowed, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::FillSecret, - site.clone(), - site, - InstructionSource::User, - SecretDelivery::RawValue, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::SecretBrokerRequired) - ); -} - -#[test] -fn explicit_extension_grant_cannot_attach_secret_material_to_non_secret_action() { - let grant = action_proposal_grant(); - assert_extension_can_only_propose(&grant); - - let site = origin("https://app.example"); - let context = PolicyContext::new( - SessionMode::AgentTask, - ExecutionPurpose::UserDelegatedTask, - BTreeSet::from([Capability::Navigate]), - BTreeSet::from([site.clone()]), - BTreeSet::new(), - RobotsDecision::Allowed, - ApprovalEvidence::None, - ); - let proposed = ActionRequest::new( - ActionKind::Navigate, - site.clone(), - site, - InstructionSource::User, - SecretDelivery::RawValue, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &context), - Decision::Deny(DenialReason::UnexpectedSecretMaterial) - ); -} diff --git a/crates/originweave-policy/tests/extension_secret_isolation.rs b/crates/originweave-policy/tests/extension_secret_isolation.rs deleted file mode 100644 index f808bec04..000000000 --- a/crates/originweave-policy/tests/extension_secret_isolation.rs +++ /dev/null @@ -1,96 +0,0 @@ -#![allow(clippy::expect_used)] - -use std::collections::BTreeSet; - -use originweave_core::{ - ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, - BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, - ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, - InstructionSource, Origin, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, - SessionMode, evaluate_extension_access, -}; -use originweave_policy::{Decision, evaluate}; - -const VALID_INTENT: &str = - "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; -const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; - -fn extension_id() -> ExtensionId { - ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") -} - -fn browser_session() -> BrowserSessionId { - BrowserSessionId::new(7).expect("nonzero browser session") -} - -fn browsing_context() -> BrowsingContextId { - BrowsingContextId::new(11).expect("nonzero browsing context") -} - -fn origin() -> Origin { - Origin::parse("https://login.example").expect("valid test origin") -} - -fn intent() -> ActionIntentDigest { - ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") -} - -fn action_proposal_grant() -> ExtensionAgentGrant { - ExtensionAgentGrant::new( - extension_id(), - browser_session(), - browsing_context(), - origin(), - UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, - [ExtensionAgentCapability::ProposeTypedAction], - ) -} - -fn assert_extension_can_propose(grant: &ExtensionAgentGrant) { - let request = ExtensionAccessRequest::new( - extension_id(), - browser_session(), - browsing_context(), - origin(), - UNEXPIRED_NOW_EPOCH_SECONDS, - ExtensionAgentCapability::ProposeTypedAction, - ); - assert_eq!( - evaluate_extension_access(&request, Some(grant)), - ExtensionAccessDecision::Allow - ); -} - -fn secret_context(site: &Origin) -> PolicyContext { - PolicyContext::new( - SessionMode::AgentTask, - ExecutionPurpose::UserDelegatedTask, - BTreeSet::from([Capability::FillSecret]), - BTreeSet::from([site.clone()]), - BTreeSet::from([site.clone()]), - RobotsDecision::Allowed, - ApprovalEvidence::None, - ) -} - -#[test] -fn extension_action_grant_cannot_skip_secret_broker_approval() { - let grant = action_proposal_grant(); - assert_extension_can_propose(&grant); - - let site = origin(); - let proposed = ActionRequest::new( - ActionKind::FillSecret, - site.clone(), - site.clone(), - InstructionSource::User, - SecretDelivery::BrokerHandle, - intent(), - ); - - assert_eq!( - evaluate(&proposed, &secret_context(&site)), - Decision::RequireApproval(RiskClass::R3) - ); -} diff --git a/crates/originweave-policy/tests/mcp_route_binding.rs b/crates/originweave-policy/tests/mcp_route_binding.rs deleted file mode 100644 index 8e9661af6..000000000 --- a/crates/originweave-policy/tests/mcp_route_binding.rs +++ /dev/null @@ -1,96 +0,0 @@ -#![allow(clippy::expect_used)] - -use std::collections::BTreeSet; - -use originweave_core::mcp::{MCP_PROTOCOL_VERSION, MCP_TOOLS_CALL_METHOD, ValidatedMcpToolCall}; -use originweave_core::{ - ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, Capability, ExecutionPurpose, - InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, -}; -use originweave_policy::{Decision, DenialReason, evaluate_mcp}; - -const VALID_INTENT: &str = - "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - -fn origin() -> Origin { - Origin::parse("https://mcp.example").expect("valid test origin") -} - -fn intent() -> ActionIntentDigest { - ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") -} - -fn validated_call(tool_name: &str) -> ValidatedMcpToolCall { - ValidatedMcpToolCall::new( - MCP_PROTOCOL_VERSION, - MCP_TOOLS_CALL_METHOD, - tool_name, - MCP_TOOLS_CALL_METHOD, - tool_name, - ) - .expect("known test MCP tool") -} - -fn request(action: ActionKind) -> ActionRequest { - let site = origin(); - ActionRequest::new( - action, - site.clone(), - site, - InstructionSource::User, - SecretDelivery::None, - intent(), - ) -} - -fn context(capabilities: BTreeSet) -> PolicyContext { - let site = origin(); - PolicyContext::new( - SessionMode::AgentTask, - ExecutionPurpose::UserDelegatedTask, - capabilities, - BTreeSet::from([site.clone()]), - BTreeSet::from([site]), - RobotsDecision::Allowed, - ApprovalEvidence::None, - ) -} - -#[test] -fn matching_mcp_route_enters_the_existing_policy_boundary() { - let call = validated_call("originweave.observe"); - let decision = evaluate_mcp( - &call, - &request(ActionKind::Observe), - &context(BTreeSet::from([Capability::Observe])), - ); - - assert_eq!(decision, Decision::Allow); -} - -#[test] -fn mismatched_mcp_route_cannot_be_reinterpreted_as_another_action() { - let call = validated_call("originweave.observe"); - let decision = evaluate_mcp( - &call, - &request(ActionKind::Navigate), - &context(BTreeSet::from([Capability::Navigate])), - ); - - assert_eq!(decision, Decision::Deny(DenialReason::McpActionMismatch)); -} - -#[test] -fn matching_mcp_route_does_not_bypass_existing_policy_denials() { - let call = validated_call("originweave.navigate"); - let decision = evaluate_mcp( - &call, - &request(ActionKind::Navigate), - &context(BTreeSet::from([Capability::Observe])), - ); - - assert_eq!( - decision, - Decision::Deny(DenialReason::MissingCapability(Capability::Navigate)) - ); -} diff --git a/crates/originweave-resource/src/lib.rs b/crates/originweave-resource/src/lib.rs index 35a30789a..8c77aa3d0 100644 --- a/crates/originweave-resource/src/lib.rs +++ b/crates/originweave-resource/src/lib.rs @@ -9,8 +9,6 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] -use std::fmt; - /// A validation error in a resource budget. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BudgetError { @@ -20,19 +18,6 @@ pub enum BudgetError { SoftExceedsHard, } -impl fmt::Display for BudgetError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ZeroLimit => formatter.write_str("resource budget limits must be nonzero"), - Self::SoftExceedsHard => { - formatter.write_str("resource budget soft limits must not exceed hard limits") - } - } - } -} - -impl std::error::Error for BudgetError {} - /// Validated resource limits for one agent task. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ResourceBudget { diff --git a/crates/originweave-resource/tests/error_contract.rs b/crates/originweave-resource/tests/error_contract.rs deleted file mode 100644 index cc8b88dfb..000000000 --- a/crates/originweave-resource/tests/error_contract.rs +++ /dev/null @@ -1,21 +0,0 @@ -use originweave_resource::BudgetError; -use std::error::Error as _; - -#[test] -fn budget_errors_expose_stable_standard_error_contract() { - let cases = [ - ( - BudgetError::ZeroLimit, - "resource budget limits must be nonzero", - ), - ( - BudgetError::SoftExceedsHard, - "resource budget soft limits must not exceed hard limits", - ), - ]; - - for (error, expected_message) in cases { - assert_eq!(error.to_string(), expected_message); - assert!(error.source().is_none()); - } -} diff --git a/crates/originweave-tls/src/lib.rs b/crates/originweave-tls/src/lib.rs index 9024946f4..f9ec5e877 100644 --- a/crates/originweave-tls/src/lib.rs +++ b/crates/originweave-tls/src/lib.rs @@ -14,7 +14,6 @@ mod evidence; mod handshake; mod identity; mod policy; -mod revocation; mod trust; mod validity; @@ -30,7 +29,6 @@ pub use policy::{ MAX_MINIMUM_LEAF_VALIDITY, MAX_SERVER_CERTIFICATE_BYTES, MAX_SERVER_CERTIFICATE_COUNT, MAX_TLS_HANDSHAKE_TIMEOUT, TlsClientPolicy, }; -pub use revocation::{RevocationMaterialFreshness, RevocationMaterialFreshnessError}; pub use trust::{ MAX_TRUST_ROOT_BYTES, MAX_TRUST_ROOT_COUNT, TrustBundleIdentifier, TrustRootBundle, }; diff --git a/crates/originweave-tls/src/revocation.rs b/crates/originweave-tls/src/revocation.rs deleted file mode 100644 index e500125a2..000000000 --- a/crates/originweave-tls/src/revocation.rs +++ /dev/null @@ -1,174 +0,0 @@ -use std::fmt; - -/// A deterministic freshness window for independently verified revocation material. -/// -/// This value does not fetch, parse, authenticate, or interpret OCSP responses or -/// certificate revocation lists. A trusted adapter must first obtain and -/// cryptographically validate the revocation material, then pass the signed -/// `thisUpdate` and `nextUpdate` timestamps into this authority together with a -/// caller-selected local maximum freshness window. Passing this check proves only -/// that the supplied material is within both its signed interval and the caller's -/// bounded freshness policy; it does not prove that any certificate is unrevoked. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RevocationMaterialFreshness { - this_update_unix_seconds: u64, - next_update_unix_seconds: u64, - maximum_window_seconds: u64, -} - -impl RevocationMaterialFreshness { - /// Create a non-empty, locally bounded freshness window from trusted signed timestamps. - /// - /// The signed window is half-open: `thisUpdate <= trusted_time < nextUpdate`. - /// Equal or reversed timestamps fail closed because they provide no usable - /// interval. `maximum_window_seconds` is a separate local policy ceiling and - /// must be nonzero; signed material whose declared interval exceeds that - /// ceiling is rejected even if its timestamps are otherwise well-formed. - pub const fn new( - this_update_unix_seconds: u64, - next_update_unix_seconds: u64, - maximum_window_seconds: u64, - ) -> Result { - if next_update_unix_seconds <= this_update_unix_seconds { - return Err(RevocationMaterialFreshnessError::InvalidWindow { - this_update_unix_seconds, - next_update_unix_seconds, - }); - } - if maximum_window_seconds == 0 { - return Err(RevocationMaterialFreshnessError::ZeroMaximumWindow); - } - - let window_seconds = next_update_unix_seconds - this_update_unix_seconds; - if window_seconds > maximum_window_seconds { - return Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { - window_seconds, - maximum_window_seconds, - }); - } - - Ok(Self { - this_update_unix_seconds, - next_update_unix_seconds, - maximum_window_seconds, - }) - } - - /// Return the signed time at which the revocation material becomes current. - #[must_use] - pub const fn this_update_unix_seconds(self) -> u64 { - self.this_update_unix_seconds - } - - /// Return the signed time at which this freshness window stops being usable. - #[must_use] - pub const fn next_update_unix_seconds(self) -> u64 { - self.next_update_unix_seconds - } - - /// Return the caller-selected maximum accepted signed-window duration. - #[must_use] - pub const fn maximum_window_seconds(self) -> u64 { - self.maximum_window_seconds - } - - /// Evaluate one trusted time against the half-open freshness window. - /// - /// A time before `thisUpdate` is not yet usable. A time equal to or later - /// than `nextUpdate` is stale. Both cases fail closed without making any - /// statement about the certificate's revocation state. - pub const fn evaluate( - self, - trusted_time_unix_seconds: u64, - ) -> Result<(), RevocationMaterialFreshnessError> { - if trusted_time_unix_seconds < self.this_update_unix_seconds { - Err(RevocationMaterialFreshnessError::NotYetValid { - trusted_time_unix_seconds, - this_update_unix_seconds: self.this_update_unix_seconds, - }) - } else if trusted_time_unix_seconds >= self.next_update_unix_seconds { - Err(RevocationMaterialFreshnessError::Expired { - trusted_time_unix_seconds, - next_update_unix_seconds: self.next_update_unix_seconds, - }) - } else { - Ok(()) - } - } -} - -/// A deterministic reason that verified revocation material is not fresh enough to use. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RevocationMaterialFreshnessError { - /// The supplied signed timestamps do not define a non-empty freshness window. - InvalidWindow { - /// Signed `thisUpdate` timestamp in Unix seconds. - this_update_unix_seconds: u64, - /// Signed `nextUpdate` timestamp in Unix seconds. - next_update_unix_seconds: u64, - }, - /// The caller supplied no positive local maximum freshness duration. - ZeroMaximumWindow, - /// The material's signed interval exceeds the caller's local freshness ceiling. - WindowExceedsMaximum { - /// Duration of the signed `thisUpdate` to `nextUpdate` interval in seconds. - window_seconds: u64, - /// Caller-selected maximum accepted interval in seconds. - maximum_window_seconds: u64, - }, - /// Trusted time falls before the material's signed `thisUpdate` timestamp. - NotYetValid { - /// Trusted evaluation time in Unix seconds. - trusted_time_unix_seconds: u64, - /// Signed `thisUpdate` timestamp in Unix seconds. - this_update_unix_seconds: u64, - }, - /// Trusted time is equal to or later than the material's signed `nextUpdate` timestamp. - Expired { - /// Trusted evaluation time in Unix seconds. - trusted_time_unix_seconds: u64, - /// Signed `nextUpdate` timestamp in Unix seconds. - next_update_unix_seconds: u64, - }, -} - -impl fmt::Display for RevocationMaterialFreshnessError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidWindow { - this_update_unix_seconds, - next_update_unix_seconds, - } => write!( - formatter, - "revocation material window is invalid: thisUpdate {this_update_unix_seconds} must be before nextUpdate {next_update_unix_seconds}", - ), - Self::ZeroMaximumWindow => write!( - formatter, - "revocation material maximum freshness window must be greater than zero", - ), - Self::WindowExceedsMaximum { - window_seconds, - maximum_window_seconds, - } => write!( - formatter, - "revocation material window is {window_seconds} seconds, exceeding the local maximum of {maximum_window_seconds} seconds", - ), - Self::NotYetValid { - trusted_time_unix_seconds, - this_update_unix_seconds, - } => write!( - formatter, - "revocation material is not usable at trusted time {trusted_time_unix_seconds}; thisUpdate is {this_update_unix_seconds}", - ), - Self::Expired { - trusted_time_unix_seconds, - next_update_unix_seconds, - } => write!( - formatter, - "revocation material is stale at trusted time {trusted_time_unix_seconds}; nextUpdate is {next_update_unix_seconds}", - ), - } - } -} - -impl std::error::Error for RevocationMaterialFreshnessError {} diff --git a/crates/originweave-tls/src/trust.rs b/crates/originweave-tls/src/trust.rs index 32aa66e17..f3e3374b6 100644 --- a/crates/originweave-tls/src/trust.rs +++ b/crates/originweave-tls/src/trust.rs @@ -19,7 +19,6 @@ impl TrustBundleIdentifier { pub fn parse(input: &str) -> Result { if input.is_empty() || input.len() > 128 - || !input.bytes().any(|byte| byte.is_ascii_alphanumeric()) || !input.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-') }) diff --git a/crates/originweave-tls/tests/policy_contract.rs b/crates/originweave-tls/tests/policy_contract.rs index 4fad353b3..5a8b71ef4 100644 --- a/crates/originweave-tls/tests/policy_contract.rs +++ b/crates/originweave-tls/tests/policy_contract.rs @@ -25,7 +25,7 @@ fn trust_bundle_identifier_is_bounded_and_ascii() { TrustBundleIdentifier::parse("enterprise_roots:v1").expect("valid trust bundle identifier"); assert_eq!(identifier.as_str(), "enterprise_roots:v1"); - for invalid in ["", "contains space", "한글", "slash/value", "---"] { + for invalid in ["", "contains space", "한글", "slash/value"] { assert!(matches!( TrustBundleIdentifier::parse(invalid), Err(TlsError::InvalidTrustBundleIdentifier) diff --git a/crates/originweave-tls/tests/revocation_freshness.rs b/crates/originweave-tls/tests/revocation_freshness.rs deleted file mode 100644 index c7af7bd7c..000000000 --- a/crates/originweave-tls/tests/revocation_freshness.rs +++ /dev/null @@ -1,119 +0,0 @@ -use std::error::Error as _; - -use originweave_tls::{RevocationMaterialFreshness, RevocationMaterialFreshnessError}; - -const MAXIMUM_WINDOW_SECONDS: u64 = 300; - -#[test] -fn revocation_material_freshness_uses_a_half_open_verified_window() { - let freshness = RevocationMaterialFreshness::new(1_000, 1_100, MAXIMUM_WINDOW_SECONDS); - assert!(freshness.is_ok()); - - if let Ok(freshness) = freshness { - assert_eq!(freshness.this_update_unix_seconds(), 1_000); - assert_eq!(freshness.next_update_unix_seconds(), 1_100); - assert_eq!(freshness.maximum_window_seconds(), MAXIMUM_WINDOW_SECONDS); - assert_eq!(freshness.evaluate(1_000), Ok(())); - assert_eq!(freshness.evaluate(1_099), Ok(())); - assert_eq!( - freshness.evaluate(999), - Err(RevocationMaterialFreshnessError::NotYetValid { - trusted_time_unix_seconds: 999, - this_update_unix_seconds: 1_000, - }) - ); - assert_eq!( - freshness.evaluate(1_100), - Err(RevocationMaterialFreshnessError::Expired { - trusted_time_unix_seconds: 1_100, - next_update_unix_seconds: 1_100, - }) - ); - } -} - -#[test] -fn revocation_material_freshness_rejects_empty_or_reversed_windows() { - for (this_update, next_update) in [(1_000, 1_000), (1_001, 1_000)] { - assert_eq!( - RevocationMaterialFreshness::new(this_update, next_update, MAXIMUM_WINDOW_SECONDS), - Err(RevocationMaterialFreshnessError::InvalidWindow { - this_update_unix_seconds: this_update, - next_update_unix_seconds: next_update, - }) - ); - } -} - -#[test] -fn revocation_material_freshness_requires_a_bounded_local_policy_window() { - assert_eq!( - RevocationMaterialFreshness::new(1_000, 1_100, 0), - Err(RevocationMaterialFreshnessError::ZeroMaximumWindow) - ); - - let exact_maximum = RevocationMaterialFreshness::new(1_000, 1_300, MAXIMUM_WINDOW_SECONDS); - assert!(exact_maximum.is_ok()); - - assert_eq!( - RevocationMaterialFreshness::new(1_000, 1_301, MAXIMUM_WINDOW_SECONDS), - Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { - window_seconds: 301, - maximum_window_seconds: MAXIMUM_WINDOW_SECONDS, - }) - ); - - assert_eq!( - RevocationMaterialFreshness::new(1, u64::MAX, 1), - Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { - window_seconds: u64::MAX - 1, - maximum_window_seconds: 1, - }) - ); -} - -#[test] -fn revocation_freshness_errors_are_stable_and_source_free() { - let invalid = RevocationMaterialFreshnessError::InvalidWindow { - this_update_unix_seconds: 1_000, - next_update_unix_seconds: 1_000, - }; - let zero_maximum = RevocationMaterialFreshnessError::ZeroMaximumWindow; - let too_long = RevocationMaterialFreshnessError::WindowExceedsMaximum { - window_seconds: 301, - maximum_window_seconds: MAXIMUM_WINDOW_SECONDS, - }; - let future = RevocationMaterialFreshnessError::NotYetValid { - trusted_time_unix_seconds: 999, - this_update_unix_seconds: 1_000, - }; - let stale = RevocationMaterialFreshnessError::Expired { - trusted_time_unix_seconds: 1_100, - next_update_unix_seconds: 1_100, - }; - - assert_eq!( - invalid.to_string(), - "revocation material window is invalid: thisUpdate 1000 must be before nextUpdate 1000" - ); - assert_eq!( - zero_maximum.to_string(), - "revocation material maximum freshness window must be greater than zero" - ); - assert_eq!( - too_long.to_string(), - "revocation material window is 301 seconds, exceeding the local maximum of 300 seconds" - ); - assert_eq!( - future.to_string(), - "revocation material is not usable at trusted time 999; thisUpdate is 1000" - ); - assert_eq!( - stale.to_string(), - "revocation material is stale at trusted time 1100; nextUpdate is 1100" - ); - - for error in [invalid, zero_maximum, too_long, future, stale] { - assert!(error.source().is_none()); - } -} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index f750922fd..4ac62061d 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -227,6 +227,8 @@ Queries the OriginWeave semantic observation contract; callers do not need to sy Query predicates may include role, accessible name, state, structured field, source channel and scoped layout attributes. Results contain opaque semantic-node handles bound to the current session/context/origin/document epoch. +The first Rust control-plane slice admits an untrusted WebDriver BiDi `locateNodes` result only after the adapter transfers a non-cloneable `QueryNodes` / `SemanticObservation` protocol-use proof into `bind_current_nodes` on the exact current session, browsing context, canonical origin, and document epoch. Navigation or TypedInput proofs fail closed. That composition still performs no browser I/O and does not authorize typed input. + ## 14. Action operations ### `browser.act` diff --git a/docs/README.md b/docs/README.md index 1ea57ad29..03b573c54 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,7 +22,6 @@ - [OriginWeave API and protocol contract](API_CONTRACT.md) - [Release and rollback contract](RELEASE_AND_ROLLBACK.md) - [Product roadmap](product-roadmap.md) -- [Product and technical gap baseline](product-technical-gap-baseline.md) - [Research and standards](doctoring.md) - [Browser and Agent protocol standards evidence](doctoring/browser-agent-protocols.md) - [Current product-baseline standards addendum](doctoring/product-documentation-baseline.md) @@ -87,12 +86,4 @@ Proposed ADRs are reviewable architecture memory, not shipped behavior and not a The second group exists only on this documentation branch until the branch integrates. After integration, the heading remains useful historical provenance; it does not promote either ADR from Proposed to Accepted and it does not claim that the described runtime capability is implemented. -### Proposed decisions introduced by active feature work - -- [ADR 0016: BAP task lifecycle and state authority](adr/0016-bap-task-lifecycle-authority.md) - -ADR 0016 is owned by this active BAP lifecycle feature branch and remains Proposed. Its presence here makes the branch documentation graph complete without presenting the decision or implementation as protected-main truth before integration. - -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 from Proposed or assert implementation maturity. - See the [ADR index](adr/README.md) for status rules, required decision structure, supersession rules, and active feature ADRs. The index and each ADR's own status metadata must agree; a PR body, chat transcript, automation prompt, or stale issue reference cannot change ADR status. diff --git a/docs/TRD.md b/docs/TRD.md index 0e60e5ca5..3e8030012 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -25,7 +25,7 @@ The current reusable Rust control plane is intentionally smaller than the final | Module / boundary | Current responsibility | Protected-main status | Active/non-shipped evidence | |---|---|---|---| -| `originweave-core` | Canonical origin, typed actions, purpose/mode, capabilities, risk, secret-delivery, approval, session/context/document/node authority values. | **Implemented** | Active origin-bound `ExtensionAgentGrant` evaluation adds canonical-origin matching and exclusive trusted-time expiry; it is not protected-main truth until merge | +| `originweave-core` | Canonical origin, typed actions, purpose/mode, capabilities, risk, secret-delivery, approval, session/context/document/node authority values. | **Implemented** | PR #40 builds a protocol-ID registry on top of these values; it is not protected-main truth | | `originweave-policy` | Pure fail-closed action policy including purpose-bound sensitive-data authority. | **Implemented** | Trusted broker/runtime lifecycle remains separate planned work under issue #10 | | `originweave-destination` | Resolved-address classification, origin-bound snapshots, route authority, connection pinning, rebinding and redirect authority. | **Implemented** | PAC evaluation/proxy transport/CONNECT are still Planned | | `originweave-network` | Direct single-address TCP connection plan and exact operating-system peer verification. | **Implemented** | — | diff --git a/docs/adr/0010-session-context-bound-node-authority.md b/docs/adr/0010-session-context-bound-node-authority.md index 9c085bdd3..97e792948 100644 --- a/docs/adr/0010-session-context-bound-node-authority.md +++ b/docs/adr/0010-session-context-bound-node-authority.md @@ -30,6 +30,7 @@ The numeric identifiers are internal opaque registry identities. They are not ra - The Rust core stays independent of Chromium, WebDriver, selectors, script execution, network access, storage, credentials, and model providers. - Future WebDriver BiDi and CDP adapters must own external-to-internal identity translation, registry lifecycle, epoch rotation, and immediate pre-action validation. - A valid handle proves only observation authority. It does not grant a browser capability, origin permission, resolved-destination authority, transport authority, approval, or successful post-condition. +- QueryNodes admission transfers a non-cloneable SemanticObservation protocol-use proof by ownership into `bind_current_nodes` before translating an admitted `locateNodes` `sharedId` into an `ObservedNodeHandle`. Navigation or TypedInput proofs fail closed and cannot mint observation handles. ## References diff --git a/docs/adr/0013-manifest-v3-extension-authority.md b/docs/adr/0013-manifest-v3-extension-authority.md index 8feacbf27..e620edf9d 100644 --- a/docs/adr/0013-manifest-v3-extension-authority.md +++ b/docs/adr/0013-manifest-v3-extension-authority.md @@ -92,7 +92,7 @@ No persistent database migration is introduced. A release can roll back the Chro ## Open follow-ups -- Complete issue #27's compatibility matrix and production isolation acceptance. Exclusive trusted-time expiry on origin-bound `ExtensionAgentGrant` evaluation is the next protected-main candidate; task identity binding remains open. +- Complete issue #27's compatibility matrix and production isolation acceptance. - Define managed-extension identity/update semantics. - Implement the native-messaging allow-list/process boundary before claiming support. - Integrate the complete Agent Task browser vertical slice under issue #28. diff --git a/docs/adr/0016-bap-task-lifecycle-authority.md b/docs/adr/0016-bap-task-lifecycle-authority.md deleted file mode 100644 index 54fae8607..000000000 --- a/docs/adr/0016-bap-task-lifecycle-authority.md +++ /dev/null @@ -1,123 +0,0 @@ -# ADR 0016: BAP task lifecycle and state authority - -- **Status:** Proposed -- **Date:** 2026-08-22 -- **Supersedes:** None -- **Superseded by:** None - -## Context - -OriginWeave needs a deterministic lifecycle primitive for governed browser-agent work before durable BAP transport, persistence, idempotency, or crash recovery can be added safely. A task state is security-relevant because downstream components may use it to decide whether work may start, resume, complete, reconcile, or terminate. If adapters, persistence layers, browser drivers, or recovery code can mint state independently, OriginWeave would inherit ambient execution authority from whichever boundary supplied the most convenient state value. - -The `originweave-bap` crate therefore introduces a typed in-memory state machine with monotonic transition receipts and fail-closed recovery validation. The crate deliberately owns no browser, network, model, secret, approval, persistence, tenant-authentication, or protocol authority. External protocols may project lifecycle intent into this kernel, but protocol metadata cannot bypass its transition rules or upgrade a task's authority. - -## Decision drivers - -- Keep task-state authority explicit and deterministic rather than distributed across protocol adapters. -- Prevent stale, unreachable, or terminal lifecycle snapshots from reopening governed work. -- Preserve a monotonic transition sequence suitable for later durable replay evidence without claiming persistence today. -- Separate lifecycle state from browser, network, secret, model, approval, and tenant authority. -- Make waiting, checkpoint, reconciliation, completion, cancellation, expiry, and dead-letter behavior typed and testable. -- Keep recovery validation fail closed when a supplied state/sequence pair cannot arise from the reviewed state machine. - -## Assumptions and authority boundaries - -- The lifecycle is an in-memory logical primitive; it is not a durable task repository. -- Creating or restoring a lifecycle does not authenticate a caller, tenant, browser session, document, origin, destination, secret, model, approval, or external side effect. -- A transition receipt proves only what this in-memory lifecycle instance accepted. It is not durable audit evidence until a separate authenticated persistence boundary stores it. -- Waiting for approval is a lifecycle condition, not proof that approval exists. A later approval authority must independently authenticate and authorize any decision before resumption. -- `Succeeded` is entered only after a caller asserts that its separately governed post-condition has been verified; the lifecycle does not itself verify that post-condition. -- Reconciliation and dead-letter states preserve control-flow intent only. Durable reconciliation evidence remains the responsibility of a later persistence/recovery boundary. - -## Options considered - -### Let each BAP or MCP adapter own its own state machine - -Rejected. Adapter-local state machines would duplicate policy, make recovery semantics drift by protocol, and allow external protocol metadata to become implicit OriginWeave execution authority. - -### Store task state as an unrestricted string or integer - -Rejected. Untyped state admits unknown values, weakens exhaustive transition review, and makes invalid or stale recovery snapshots difficult to reject deterministically. - -### Allow restored state to resume whenever the state name looks resumable - -Rejected. State-only recovery loses monotonic history. A state/sequence pair that cannot be reached through the reviewed transitions must fail closed rather than becoming execution authority. - -### Centralize logical lifecycle transitions in a typed Rust kernel - -Selected. - -## Decision - -If Accepted, OriginWeave applies these lifecycle rules: - -1. **One typed kernel owns logical BAP task state.** `originweave-bap` is the canonical state-transition authority for the task lifecycle represented by this contract. Protocol adapters may request transitions but do not mint lifecycle state directly. -2. **Transitions are explicit and fail closed.** The kernel accepts only reviewed event/state combinations. Invalid events preserve the existing state and sequence and return a typed error. -3. **Terminal states never reopen.** `Succeeded`, `Failed`, `Cancelled`, `Expired`, and `DeadLettered` reject later lifecycle events. -4. **Waiting and checkpoint states require explicit resumption.** Approval wait, external-input wait, and checkpoint states do not silently become running work. -5. **Reconciliation is distinct from normal suspension.** A task in `ReconciliationRequired` cannot use the ordinary resume path; it requires explicit reconciliation resolution or governed dead-letter handling. -6. **Transition sequence is monotonic and bounded.** Every accepted transition advances the sequence exactly once. Sequence exhaustion fails closed instead of wrapping. -7. **Recovery validates reachability.** A supplied state/sequence snapshot must be reachable under the same reviewed state machine. Unreachable snapshots are rejected with a typed restore error. -8. **Lifecycle state grants no ambient authority.** A `Running`, resumable, or otherwise valid lifecycle state does not authorize browser I/O, network destinations, secret resolution, model access, approvals, external protocol operations, or tenant access. Those authorities must be revalidated by their owning boundaries. -9. **Durability is a separate owner.** This contract does not claim atomic persistence, idempotency, locking, authenticated replay evidence, side-effect reconciliation, or crash-safe recovery. Later durable components must bind those concerns to lifecycle receipts without weakening this state authority. -10. **External protocol state is projected, not inherited.** BAP, MCP, WebDriver BiDi, CDP, or other adapters may translate reviewed external events into typed lifecycle requests only after their own authentication and policy checks. External state labels cannot overwrite the kernel directly. - -## Consequences - -OriginWeave gains one reviewable state authority that later transport, idempotency, persistence, and recovery slices can compose without duplicating transition semantics. Invalid transitions and unreachable recovery snapshots have deterministic typed failures, while terminal and reconciliation states have explicit closure behavior. - -The trade-off is that adapters and durable stores must perform explicit mapping and validation instead of assigning state directly. The current slice also cannot claim commercial crash recovery until durable authenticated evidence and side-effect reconciliation are implemented separately. - -## Failure and degraded behavior - -- An invalid event returns a typed transition error and leaves state/history unchanged. -- A terminal lifecycle rejects all later events rather than reopening work. -- Sequence exhaustion returns a typed failure rather than wrapping or silently reusing an identifier. -- An unreachable restored state/sequence pair is rejected rather than normalized into a nearby valid state. -- Missing browser, tenant, policy, destination, secret, approval, persistence, or recovery authority is not converted into lifecycle success. -- If a future adapter cannot map external protocol state without ambiguity, it must fail closed or require reconciliation rather than inventing a lifecycle transition. - -## Security / privacy / governance impact - -This decision narrows authority. It prevents external protocol metadata, stale snapshots, or arbitrary state assignment from becoming execution authority and keeps lifecycle state separate from sensitive-data, secret, browser, network, model, approval, and tenant boundaries. The lifecycle stores no secret values or personal-data payloads by itself. Any future persistent representation must independently satisfy OriginWeave data-governance, retention, tenant-isolation, integrity, and evidence requirements. - -## Tests and acceptance evidence - -The owning branch must keep executable evidence for: - -- the reviewed created/admitted/running/waiting/checkpointed/reconciliation/terminal transition paths; -- fail-closed invalid transitions with no sequence advancement; -- terminal irreversibility; -- cancellation and expiry across allowed pre-dispatch and suspended states; -- explicit reconciliation resolution and governed dead-letter behavior; -- monotonic transition receipts and sequence-exhaustion failure; -- recovery acceptance for reachable snapshots and rejection for unreachable snapshots; and -- deterministic public Rust error contracts. - -Repository contracts must also require this ADR so the `originweave-bap` control-plane boundary cannot remain undocumented while the crate is present. Exact protected-main acceptance still depends on current-head CI, exact owned-production coverage, rustdoc, security evidence, review, live governance, and integration state; ADR presence does not substitute for those gates. - -## Migration and rollback - -No database migration is introduced. Existing callers on this branch construct the typed lifecycle directly. A future durable task repository should persist state and transition evidence in an authenticated form that can be validated by this kernel rather than introducing a second transition authority. - -Rollback before acceptance is removal of the active BAP lifecycle branch and its Proposed ADR. After acceptance, rollback or replacement must preserve fail-closed terminal/recovery semantics or explicitly supersede this ADR with a reviewed migration for any persisted lifecycle representation. - -## Open follow-ups - -- Bind durable idempotency receipts to exact accepted transitions without making retry metadata task authority. -- Define authenticated persistence, atomicity, and concurrency semantics for lifecycle plus command evidence. -- Define crash-recovery classification and reconciliation for ambiguous external side effects. -- Map authenticated BAP/MCP transport messages into typed lifecycle requests without ambient protocol authority. -- Propagate cancellation and expiry into real browser/process supervision only after the corresponding runtime authority exists. - -## Supersession / reversal conditions - -Supersede this ADR if OriginWeave replaces the BAP lifecycle model, introduces a materially different durable event-sourced task authority, or moves canonical task-state ownership to another reviewed component. A successor must preserve explicit state authority, terminal fail-closure, monotonic recovery evidence, and the rule that lifecycle state cannot mint unrelated browser/network/secret/model/approval/tenant authority. - -## References - -ContextualWisdomLab. (2026). *OriginWeave architecture* [Repository specification]. *OriginWeave*. [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) - -ContextualWisdomLab. (2026). *OriginWeave architecture decision records* [Repository specification]. *OriginWeave*. [`README.md`](README.md) - -ContextualWisdomLab. (2026). *Agent development contract* [Repository specification]. *OriginWeave*. [`../../AGENTS.md`](../../AGENTS.md) diff --git a/docs/adr/0106-provenance-evidence-model.md b/docs/adr/0106-provenance-evidence-model.md index 09cb0d7ca..0e2741f37 100644 --- a/docs/adr/0106-provenance-evidence-model.md +++ b/docs/adr/0106-provenance-evidence-model.md @@ -33,47 +33,29 @@ OriginWeave maintains provenance-native evidence with stable identifiers for ses WARC and PROV are interoperability/export contracts, not substitutes for OriginWeave's internal authorization or evidence schema. A WARC record can contain untrusted or sensitive payload bytes and therefore inherits capture, retention, encryption, and export policy. A PROV entity/activity/agent relation records derivation or responsibility; it cannot manufacture authentication, authorization, durable completion, or tenant ownership not established by the producing system. -### Versioned extraction-schema binding - -A versioned `ExtractionSchema` is the binding contract for typed extraction before any capture persistence or export format is allowed to claim semantic authority. Each schema version contains an ordered, non-empty set of unique `ExtractionField` definitions. Schema-version and field identifiers are bounded to 128 encoded bytes, begin with a lowercase ASCII letter, and thereafter admit only lowercase ASCII letters, digits, `_`, or `-`. One schema admits at most 256 fields. - -Every extraction field binds its stable identifier to a value type, cardinality, required/optional status, deterministic normalization rule, and a non-empty duplicate-free set of reviewed source-channel classes. Cardinality and required status form one internally consistent presence contract: `One` is necessarily required, `ZeroOrOne` is necessarily optional, and `Many` may be marked required or optional because this value-object layer does not yet define a minimum collection item count. Contradictory `One`/optional or `ZeroOrOne`/required declarations fail closed during field construction. `Verbatim` is the compatibility default used by the existing constructor. `TrimTextWhitespace` is admitted only for text fields and `Rfc3339Utc` only for timestamp fields; type-incompatible normalization fails closed. A `ModelInterpretation` source channel is classification metadata only and does not grant model execution, approval, disclosure, browser, network, secret, or storage authority. - -At this value-object boundary, the version identifier is immutable schema identity; there is deliberately no registry that silently treats two different field contracts as compatible merely because their version strings compare or sort in a particular way. Callers changing a field identifier, value type, cardinality, required status, normalization rule, or admitted source-channel set must use a distinct reviewed schema version and perform any migration/compatibility decision at an explicit higher layer. The current schema object does not itself read browser data, materialize extracted values, persist artifacts, execute models, or change governance policy. Those capabilities require separately authorized runtime boundaries and are not implied by schema construction. - ## Consequences Capture becomes a designed product surface rather than incidental logging. Storage and retention need budgets. Consumers can distinguish a model claim from source evidence and an action request from verified completion. Export adapters can target WARC, provenance graphs, audit streams, or buyer-specific schemas. -A schema consumer can also determine the exact field/type/cardinality/normalization/source contract it reviewed rather than relying on free-form extraction instructions. Schema evolution is explicit instead of being inferred from mutable field definitions; runtime compatibility, migrations, durable storage, and extracted-value validation remain separate implementation work until those boundaries are delivered. - ## Failure and degraded behavior If mandatory evidence cannot be recorded durably enough for a governed state-changing action, the action fails before execution or reports an explicit unverifiable failure; it is never marked proved. Read-only operations may degrade to reduced evidence only when the API contract declares that mode. Corrupt or incomplete evidence is quarantined rather than silently accepted. -Invalid or oversized extraction identifiers, contradictory cardinality/required declarations, empty or duplicate field sets, missing or duplicate source channels, and type-incompatible normalization rules fail during schema construction. A caller must not reinterpret such a failure as an empty/default-success schema or silently substitute another source channel. - ## Security / privacy / governance impact Evidence is tenant-scoped, selectively disclosed, encrypted as appropriate, retention-bounded, and auditable. Credential-bearing headers, cookies, secret values, and sensitive form data are excluded or transformed according to explicit schema policy. Integrity metadata and immutable artifact identities support tamper detection without claiming external certification. `docs/DATA_GOVERNANCE.md` defines the disclosure/retention boundary for protected content and derived artifacts. -The extraction-schema contract does not modify governance authority. It describes admissible typed fields and reviewed evidence-channel classes only. In particular, declaring `NetworkResponse` or `ModelInterpretation` does not authorize network access, model execution, protected-data disclosure, approvals, retention, or export; those remain governed by their existing owning boundaries. - ## Tests and acceptance evidence Require provenance-link tests, credential-leak tests, integrity/corruption tests, crash-recovery tests, WARC/export conformance where implemented, PROV relation/schema tests where implemented, retention/deletion tests, tenant-isolation tests, and end-to-end checks that state-changing actions link request, policy, approval, execution, and post-condition as separate records. Export tests must prove that disabled or unauthorized source bodies never appear merely because metadata provenance is exportable. -The extraction-schema boundary additionally requires tests for the identifier grammar and limits, field-count bound, duplicate identifiers, source-channel presence and uniqueness, every reviewed value/cardinality/source-channel variant, consistent cardinality/required combinations and contradictory-combination rejection, deterministic normalization selection, incompatible normalization rejection, and the backward-compatible `Verbatim` constructor default. - ## Migration and rollback Introduce stable evidence identifiers and schema versions before changing export formats. Migrations preserve old evidence semantics or explicitly mark unavailable fields. Rollback may revert an exporter but cannot collapse mandatory action and policy evidence into opaque logs. -Extraction contract changes that alter field identity or semantics require a new reviewed schema version rather than mutating the meaning of an existing version. Rolling back a consumer may stop accepting a newer version, but it must not reinterpret that newer contract as an older one or silently discard required fields. - ## Open follow-ups -Finalize canonical evidence schemas, content-retention defaults, signing/attestation strategy, cross-system export identifiers, and buyer-controlled disclosure policies. Add the runtime that validates concrete extracted values against an `ExtractionSchema`, plus explicit migration/compatibility policy when durable schema registration is introduced. +Finalize canonical evidence schemas, content-retention defaults, signing/attestation strategy, cross-system export identifiers, and buyer-controlled disclosure policies. ## Supersession / reversal conditions diff --git a/docs/adr/0107-browser-protocol-adapter-strategy.md b/docs/adr/0107-browser-protocol-adapter-strategy.md index fb1bf2e17..8923616be 100644 --- a/docs/adr/0107-browser-protocol-adapter-strategy.md +++ b/docs/adr/0107-browser-protocol-adapter-strategy.md @@ -34,16 +34,6 @@ OriginWeave exposes its own versioned protocol for session, observation, query, MCP version negotiation is independent of the OriginWeave Protocol version. As of this review, MCP `2026-07-28` is the current released protocol generation; a future MCP change does not silently alter OriginWeave task, approval, secret, tenant, or browser semantics. MCP tool/resource content remains untrusted input and any server-to-client/user interaction capability is mediated by the same OriginWeave policy/approval boundaries as other adapter traffic. -### Current implementation boundary - -The complete MCP adapter remains **Planned**. Protected main now contains the narrower bounded Rust `tools/call` routing/action-policy foundation merged through PR #168. That protected-main foundation validates the `2026-07-28` stateless `tools/call` routing envelope presented to this boundary, bounds and syntax-checks both untrusted method fields and both untrusted tool-name fields before cross-field correlation, derives one of the existing typed `ActionKind` values from a deterministic reviewed registry, exposes discovery metadata from that same registry, and requires the resulting action to pass the ordinary OriginWeave policy evaluator. The method boundary accepts only nonempty ASCII method names up to 64 bytes using the reviewed routing alphabet, while the tool-name boundary accepts only nonempty ASCII names up to 128 bytes using its narrower reviewed alphabet. The catalog and validated route grant no capability, approval, origin, secret, browser, persistence, or evidence authority by themselves. - -Active PR #170 is a separate non-shipped refinement on top of that protected-main catalog. It adds one conservative typed `tools/list` request/result contract: both protocol-version fields are required and bounded before comparison, client-capability metadata must be present without becoming authority, both routing/body methods are syntax-bounded before correlation, only exact `tools/list` is admitted, and every caller-supplied cursor is rejected because the current fixed catalog issues none. The result is one complete page with zero freshness, private cache scope, and no continuation cursor. - -Neither protected main nor PR #170 implements Streamable HTTP transport parsing, JSON-RPC/HTTP serialization, OAuth, browser I/O, WebMCP/BiDi/CDP translation, secret delivery, persistence, general pagination/subscription state, or a complete OriginWeave Protocol adapter. Those remain separate adapter/runtime work. Protected `main` may therefore describe only the bounded merged `tools/call` foundation as implemented; the full MCP adapter remains planned, and the `tools/list` refinement remains active-PR evidence until separately integrated. - -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. - ## Consequences OriginWeave carries adapter maintenance and version negotiation but gains a durable customer API. Multiple browser/control transports can coexist. New upstream capabilities do not silently change risk or action semantics. Compatibility matrices become release artifacts. @@ -54,21 +44,19 @@ Adapter negotiation failure disables only affected capabilities. Unsupported or ## Security / privacy / governance impact -Protocol validation occurs before messages influence policy. Tool/page-provided strings remain untrusted. Method and tool routing metadata is shape-bounded before correlation, preventing malformed or oversized untrusted routing strings from being reinterpreted through mismatch handling. Secret handles never become raw secret protocol payloads; only the separately authorized trusted broker-to-browser delivery path may materialize the value, and that value does not pass through MCP, WebMCP, BiDi observation, or model-visible CDP output. Adapter version/provenance is recorded for audit and incident reconstruction. +Protocol validation occurs before messages influence policy. Tool/page-provided strings remain untrusted. Secret handles never become raw secret protocol payloads; only the separately authorized trusted broker-to-browser delivery path may materialize the value, and that value does not pass through MCP, WebMCP, BiDi observation, or model-visible CDP output. Adapter version/provenance is recorded for audit and incident reconstruction. ## Tests and acceptance evidence Require version-negotiation tests, schema/property tests, malformed-message tests, BiDi/CDP semantic parity tests for shared capabilities, WebMCP prompt-injection tests, MCP authority-separation and version-change tests, browser-version compatibility matrices, and end-to-end proof that unsupported capabilities fail without side effects. -For the protected-main `tools/call` foundation, acceptance includes deterministic method and tool-name bounds/syntax, exact header/body method and tool-name correlation only after both sides are bounded, explicit invalid-method/invalid-tool-name/unknown-tool rejection, one unambiguous tool-to-action registry, independent capability/risk expectations, route/action mismatch denial before ordinary policy evaluation, exact 100% owned-production coverage, and integrated review evidence from PR #168. For active PR #170, exact-current acceptance additionally requires bounded protocol metadata before cross-field comparison, required client-capabilities presence, bounded `tools/list` method correlation, rejection of unissued cursors, deterministic result/cache semantics, exact 100% owned-production coverage, and unchanged-head CI/security/review evidence. These checks do not substitute for complete transport or adapter conformance. - ## Migration and rollback Adapters are independently versioned and can be canaried. Clients migrate through OriginWeave Protocol compatibility rules, not upstream protocol rewrites. Rollback pins a previously supported adapter/browser/protocol pair and records that pair in provenance. ## Open follow-ups -Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, complete MCP Streamable HTTP/request-metadata validation, MCP transport serialization, authenticated deployment, and MCP/WebMCP schema isolation. +Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, and MCP/WebMCP schema isolation. ## Supersession / reversal conditions @@ -80,12 +68,10 @@ Chrome DevTools Protocol. (2026). *Chrome DevTools Protocol — latest (tip-of-t Chrome DevTools Protocol. (2026). *WebMCP domain*. Chromium. Retrieved August 9, 2026, from https://chromedevtools.github.io/devtools-protocol/tot/WebMCP/ -Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://modelcontextprotocol.io/specification/2026-07-28 - 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, June 29). *WebDriver BiDi* [Working Draft]. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260629/ ## Related documents -See `docs/API_CONTRACT.md`, `docs/TRD.md`, `docs/doctoring.md`, `docs/doctoring/product-documentation-baseline.md`, `docs/traceability/README.md`, and `docs/DATA_GOVERNANCE.md`. +See `docs/API_CONTRACT.md`, `docs/TRD.md`, `docs/doctoring/product-documentation-baseline.md`, and `docs/DATA_GOVERNANCE.md`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 5f9e2a878..416231b1c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,16 +57,6 @@ Proposed ADR files are reviewable target architecture without becoming Accepted ADR 0013 and ADR 0014 exist only on this documentation branch until it integrates. After integration, this subsection remains historical provenance rather than an active-PR claim; both decisions remain Proposed until a later policy-compliant change explicitly changes their lifecycle. -### Proposed decisions introduced by active feature work - -| ADR | Decision | Status | Governs | -|---|---|---|---| -| [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 | - -ADR 0016 belongs to the active BAP lifecycle feature branch. Indexing it makes the branch documentation graph complete while preserving its Proposed lifecycle and 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 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. ## Index completeness rule diff --git a/docs/doctoring.md b/docs/doctoring.md index ec51daaf3..866d8766f 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,7 +8,11 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it 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. +The same Working Draft defines `script.NodeRemoteValue` with a required `type` of `node` and an optional `sharedId`, and `browsingContext.locateNodes` returns a list of those remote values. A `script.SharedReference` is the protocol's node identity across realms; when both `handle` and `sharedId` are present, the protocol respects only `sharedId`. OriginWeave therefore admits a `locateNodes` result item only when the remote type is exactly `node` and a non-empty `sharedId` fits the same UTF-8 identifier budget used by browser session and context identifiers and contains no control, whitespace, or reviewed Unicode format characters. Requiring `sharedId` and rejecting control, whitespace, and format characters is a local fail-closed policy, not a claim that the Working Draft makes those fields mandatory or forbids whitespace. The admitted value is an untrusted transport handle, not an OriginWeave session, context, origin, or document-epoch node identity. The same-call QueryNodes admission boundary first obtains a non-cloneable SemanticObservation protocol-use proof and transfers that proof by ownership into `bind_current_nodes`, which refuses Navigation and TypedInput proofs before translating each admitted `sharedId` through the session-scoped registry into an `ObservedNodeHandle` only after the exact current session, browsing context, canonical origin, and document epoch are revalidated and the returned item count still fits the reviewed query budget. That composition still performs no browser I/O and does not authorize typed input. + +WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents split on whitespace and use the first matching non-abstract role. OriginWeave's first `locateNodes` accessibility query asks for one exact role, so a role containing whitespace, a control character, or a Unicode format character is rejected rather than interpreted as a fallback-role list. Accessible Name and Description Computation 1.2, a W3C Working Draft as of 5 August 2026, treats accessible names as ordinary strings that may contain spaces and treats whitespace-only `aria-roledescription` values as absent. OriginWeave therefore keeps ordinary spaces in accessible-name locators, rejects control and reviewed format characters that would become protocol-text injection or bidirectional spoofing, and rejects whitespace-only names as non-selectors. + +UTS #39 Revision 32 is the current Unicode security-mechanisms standard and marks Default_Ignorable and bidirectional format characters as restricted in identifier profiles. UAX #9 defines the bidirectional format controls that can reorder displayed protocol text. UTR #36 Revision 15 remains a stabilized historical security-considerations report; its identifier recommendations are superseded by UTS #39 rather than cited as current normative profile rules. OriginWeave therefore rejects the reviewed format-character set in roles, shared identifiers, and registry external identifiers, and rejects those same characters inside accessible names while still allowing ordinary U+0020 spaces. ### Browser origin equivalence @@ -16,20 +20,6 @@ The WHATWG URL host parser and Chromium canonicalizer classify shortened decimal The exact Chromium regression evidence is pinned to revision `446d05d21720f0b3505ec21057b3e9f909784262`. A mutable `HEAD` reference is not sufficient for a reproducible security contract. -### Extension-to-Agent grant origin binding - -RFC 6454 defines a web origin as the scheme, host, and port tuple that browsers use to isolate authority. An OriginWeave `extension_grant` that is bound only to extension identity, session, and browsing context would remain valid after the same context navigates to another origin. OriginWeave therefore requires the grant and the request to carry the same canonical origin. A host change or a non-default port change is a different origin and cannot reuse the grant. This is grant-scope isolation only; it does not install an extension, parse Chrome messages, or mint Agent capabilities from Manifest V3 permissions. - -### Extension-to-Agent grant exclusive expiry - -RFC 9700 is the current Best Current Practice for OAuth 2.0 security. It requires access tokens to be restricted in lifetime and treats long-lived bearer credentials as a standing authorization risk. An OriginWeave `extension_grant` that matches extension identity, session, browsing context, and canonical origin but has no exclusive expiry remains usable after the Agent Task window ends. OriginWeave therefore requires the grant to carry an exclusive `expires_at_epoch_seconds` deadline and the request to carry trusted `now_epoch_seconds`. Evaluation fails closed when `now >= expires_at`, matching the existing sensitive-handle exclusive-expiry rule. Page, extension, and model clocks are not trusted time. This slice does not bind task identity, install an extension, or mint Agent capabilities from Manifest V3 permissions. - -### Release-limitation presentation safety - -Unicode 17.0 defines `Default_Ignorable_Code_Point` in the Unicode Character Database and records the exact derived set in the versioned `DerivedCoreProperties.txt` data file. Those characters can be invisible or alter presentation without supplying an ordinary visible glyph. OriginWeave therefore treats the Unicode 17.0 derived property as a pinned presentation-safety input for buyer-visible release-limitation metadata, in addition to rejecting control characters and non-canonical leading or trailing whitespace. The admitted text is not silently normalized: accepted content retains its exact bytes, while ambiguous presentation characters and surrounding whitespace fail closed so one release claim cannot acquire multiple stored spellings. This is a bounded metadata-identity policy, not a claim of complete Unicode spoofing resistance or semantic text equivalence. - -Unicode Standard Annex #15, revision 57 for Unicode 17.0.0, defines canonical equivalence and NFC and states that normalized equivalent strings have a unique binary representation. A release limitation is an identity-bearing buyer artifact, so OriginWeave rejects canonically equivalent non-NFC spellings instead of silently rewriting them. The production boundary uses only `unicode_normalization::is_nfc`; accepted strings remain byte-for-byte caller input. Rust's standard library does not provide Unicode normalization, so `unicode-normalization` is pinned exactly to 0.1.25. The reviewed crate implements UAX #15 normalization, declares Rust 1.36+ compatibility (below OriginWeave's Rust 1.97.1 baseline), is dual MIT/Apache-2.0 licensed, and adds only `tinyvec`/`tinyvec_macros` transitively in this workspace lockfile. The dependency is narrow, deterministic, non-networked, and maintained through the existing locked-dependency/security-scan process; any future Unicode-version or crate-version movement requires renewed normalization and supply-chain review. - ### Resolved destination and redirect safety Canonical origin identity is not a network-destination authorization. The IANA IPv4 and IPv6 Special-Purpose Address Space registries enumerate blocks whose source, destination, forwardability, globally reachable, and protocol-reserved properties differ. Both registries were last updated on 9 October 2025 and explicitly warn that registry presence does not guarantee routability in a particular local or global context. RFC 6890 established the common special-purpose registry fields, and RFC 8190 replaced the ambiguous `global` field with `globally reachable`. @@ -90,10 +80,6 @@ RFC 9309 standardizes robots parsing, matching, error handling, and caching. It W3C PROV-O supplies interoperable Entity, Activity, Agent, derivation, attribution, and responsibility concepts. ISO 28500:2017, confirmed in 2023, defines WARC storage for protocol payloads, control information, metadata, transformations, duplicate detection, integrity, and segmentation. OriginWeave uses source hashes and locators in the safety kernel, then adds WARC and PROV adapters as separately testable modules. -The versioned `ExtractionSchema` is an admission and interpretation contract for typed extracted fields: each field is bounded, declares a value type, cardinality, normalization rule, and a canonical duplicate-free set of reviewed source-channel classes. That declaration does not create browser, network, model, secret, storage, retention, disclosure, or governance authority. PROV/WARC interoperability is therefore layered after the schema contract rather than inferred from it. - -RFC 3986 remains Internet Standard STD 66 for generic URI syntax. RFC 8820 is the current URI design-and-ownership Best Current Practice; it obsoletes RFC 7320 and updates RFC 3986 without replacing RFC 3986's path grammar. Section 3.3 of RFC 3986 defines each path segment as `*pchar`, where literal path characters are unreserved characters, sub-delimiters, `:`, or `@`; `/` separates segments and other reserved characters such as `[` and `]` are not literal `pchar`. OriginWeave's shared evidence-path validator therefore applies that literal ASCII `pchar` set plus validated percent-encoded octets and explicit slash separators to both `NetworkEvidence::capture` paths and provenance source-URL paths. Existing stricter evidence-safety rules continue to reject encoded separators, dot-segment ambiguity, controls, whitespace, query strings, fragments, backslashes, and credential-bearing authority. This fail-closed syntax tightening affects both evidence surfaces; it does not authorize the source origin, destination, network access, capture, disclosure, or retention. - ### AI risk and prompt injection NIST AI 600-1 provides generative-AI lifecycle risk guidance. WASP demonstrates that web-navigation agents can follow low-effort indirect prompt injections. OriginWeave therefore separates trusted instructions, untrusted observations, and protected secrets at type and process boundaries rather than rely on prompting alone. @@ -116,10 +102,6 @@ Amazon Web Services. (n.d.). *Set up the Amazon EKS Pod Identity Agent*. Retriev Autio, C., Schwartz, R., Dunietz, J., Jain, S., Stanley, M., Tabassi, E., Hall, P., & Roberts, K. (2024). *Artificial intelligence risk management framework: Generative artificial intelligence profile* (NIST AI 600-1). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.AI.600-1 -Barth, A. (2011). *The web origin concept* (RFC 6454). Internet Engineering Task Force. https://doi.org/10.17487/RFC6454 - -Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform resource identifier (URI): Generic syntax* (RFC 3986; STD 66). Internet Engineering Task Force. https://doi.org/10.17487/RFC3986 - Bonica, R., Cotton, M., Haberman, B., & Vegoda, L. (2017). *Updates to the special-purpose IP address registries* (RFC 8190). Internet Engineering Task Force. https://doi.org/10.17487/RFC8190 Chromium Authors. (n.d.). *Proxy support in Chrome* [Source documentation]. Chromium. https://chromium.googlesource.com/chromium/src/+/a3e71ebfa307d8760eb68b777e2998a869940092/net/docs/proxy.md @@ -152,18 +134,10 @@ International Organization for Standardization. (2017). *Information and documen Koster, M., Illyes, G., Zeller, H., & Sassman, L. (2022). *Robots Exclusion Protocol* (RFC 9309). Internet Engineering Task Force. https://doi.org/10.17487/RFC9309 -Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *OAuth 2.0 security best current practice* (RFC 9700). Internet Engineering Task Force. https://doi.org/10.17487/RFC9700 - Microsoft. (2025, July 25). *Azure IP address 168.63.129.16 overview*. Microsoft Learn. https://learn.microsoft.com/azure/virtual-network/what-is-ip-address-168-63-129-16 -Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://modelcontextprotocol.io/specification/2026-07-28 - Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). *Learning to orchestrate agents in natural language with the Conductor* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04388 -Nottingham, M. (2014). *URI design and ownership* (RFC 7320). Internet Engineering Task Force. https://doi.org/10.17487/RFC7320 - -Nottingham, M. (2020). *URI design and ownership* (RFC 8820; BCP 190). Internet Engineering Task Force. https://doi.org/10.17487/RFC8820 - Rescorla, E. (2026). *The Transport Layer Security (TLS) protocol version 1.3* (RFC 9846). Internet Engineering Task Force. https://doi.org/10.17487/RFC9846 Rustls Project Developers. (2026). *rustls 0.23.42* [Computer software]. https://docs.rs/rustls/0.23.42/rustls/ @@ -182,18 +156,22 @@ The Rust Project Developers. (2026). *Ipv6Addr in std::net* (Rust 1.97.1) [Softw The Rust Project Developers. (2026). *TcpStream in std::net* (Rust 1.97.1) [Software documentation]. https://doc.rust-lang.org/stable/std/net/struct.TcpStream.html -The Unicode Consortium. (2025). *DerivedCoreProperties-17.0.0.txt* [Data file]. https://www.unicode.org/Public/17.0.0/ucd/DerivedCoreProperties.txt +Unicode Consortium. (2014, September 19). *Unicode security considerations* (Unicode Technical Report #36, Revision 15). https://www.unicode.org/reports/tr36/tr36-15.html -The Unicode Consortium. (2025, July 30). *Unicode Standard Annex #15: Unicode normalization forms* (Revision 57, Unicode 17.0.0). https://www.unicode.org/reports/tr15/ +Unicode Consortium. (2025a, September 4). *Unicode bidirectional algorithm* (Unicode Standard Annex #9, Version 17.0.0). https://www.unicode.org/reports/tr9/ -Unicode-RS Project Developers. (2025). *unicode-normalization 0.1.25* [Computer software]. https://docs.rs/unicode-normalization/0.1.25/unicode_normalization/ +Unicode Consortium. (2025b, September 4). *Unicode security mechanisms* (Unicode Technical Standard #39, Revision 32). https://www.unicode.org/reports/tr39/tr39-32.html Web Hypertext Application Technology Working Group. (2026). *URL standard*. https://url.spec.whatwg.org/ World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ +World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/2023/REC-wai-aria-1.2-20230606/ + World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ +World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ + Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04695 Zhou, S., Xu, F. F., Zhu, H., Zhou, X., Lo, R., Sridhar, A., Cheng, X., Ou, T., Bisk, Y., Fried, D., Alon, U., & Neubig, G. (2023). *WebArena: A realistic web environment for building autonomous agents*. arXiv. https://doi.org/10.48550/arXiv.2307.13854 diff --git a/docs/doctoring/browser-agent-protocols.md b/docs/doctoring/browser-agent-protocols.md index 052ecf2aa..dbf3ef731 100644 --- a/docs/doctoring/browser-agent-protocols.md +++ b/docs/doctoring/browser-agent-protocols.md @@ -8,9 +8,15 @@ This addendum complements the main doctoring record. The main record already car ## WebDriver BiDi -The W3C publication reviewed for this baseline is the 1 June 2026 **Working Draft**, not a Recommendation. OriginWeave therefore treats BiDi as a versioned browser-automation adapter rather than product-internal authority. Raw BiDi session/context/node identifiers do not become durable OriginWeave identities. +The latest published W3C technical-report baseline reviewed here remains the 1 June 2026 **Working Draft**, not a Recommendation. The current Editor’s Draft reviewed on 18 August 2026 identifies itself as the 20 July 2026 draft. OriginWeave therefore treats BiDi as a versioned browser-automation adapter rather than product-internal authority. Raw BiDi session/context/node identifiers do not become durable OriginWeave identities. -Primary source: World Wide Web Consortium, *WebDriver BiDi*. +For the bounded `browsingContext.locateNodes` command-serialization boundary, the reviewed Editor’s Draft defines a command envelope with `id: js-uint`, defines `js-uint` as `0..9007199254740991`, and defines `browsingContext.locateNodes` parameters containing a browsing context, locator, optional positive `maxNodeCount`, optional `serializationOptions`, and optional `startNodes`. OriginWeave serializes only its separately reviewed accessibility-locator subset and fixed minimal serialization options; this deterministic JSON value is not transport authentication or browser/Agent authority. + +WebDriver BiDi commands may execute concurrently and finish out of order. The Editor’s Draft defines the command id as the local end’s correlation identifier and sets a successful `CommandResponse.id` to that exact command id; an `ErrorResponse.id` may be `null` when no valid command id can be recovered. OriginWeave therefore fails closed unless a non-null protocol-range response id exactly matches the consumed command before later payload admission. Parsing success/error envelopes, handling nullable malformed-command errors, and authenticating the browser transport remain separate adapter boundaries. + +The same reviewed Editor’s Draft defines a closed `ErrorCode` vocabulary that currently includes `no such client window`. OriginWeave admits only the reviewed vocabulary at its bounded response-envelope parser and rejects unknown error-code text fail closed; adding a newly reviewed protocol code changes compatibility only and grants no browser, transport, node, policy, or Agent authority. + +Primary sources: World Wide Web Consortium, *WebDriver BiDi* (published Working Draft and current Editor’s Draft). ## Chrome Manifest V3 @@ -38,10 +44,6 @@ Primary sources: Chrome for Developers, *WebMCP*; *WebMCP tool security*; *Agent The Model Context Protocol project released specification version `2026-07-28` on 28 July 2026. That release moved the protocol core toward stateless request/response operation and removed the earlier protocol-session assumptions described by previous releases. OriginWeave therefore keeps durable browser state in explicit OriginWeave application handles and exposes MCP only as a high-level adapter to the Rust runtime. MCP clients or servers do not connect models directly to Chromium/CDP authority. -The final `2026-07-28` schema requires every client request to carry `io.modelcontextprotocol/protocolVersion` and `io.modelcontextprotocol/clientCapabilities` in request `_meta`; client capabilities are request-scoped and servers must not infer them from prior requests. `io.modelcontextprotocol/clientInfo` is optional/SHOULD rather than authorization evidence. For Streamable HTTP, `MCP-Protocol-Version` must agree with the body protocol version, `Mcp-Method` is required for every request, and `Mcp-Name` is required only for named operations such as `tools/call`, `resources/read`, and `prompts/get`, not `tools/list`. OriginWeave's typed `tools/list` admission boundary therefore independently requires the transport protocol-version header and body `_meta` protocol version, rejects disagreement or an unsupported generation, requires per-request client-capabilities presence without treating its contents as OriginWeave authority, validates routing/body `tools/list` method agreement, and does not invent a name header. It rejects any supplied cursor because the current fixed catalog emits no `nextCursor`; this is a conservative local invariant against accepting pagination state OriginWeave never issued, not a claim that MCP forbids `tools/list` cursors generally. - -The same specification requires every Result to carry `resultType`, using `complete` for a terminal result, and adds explicit cache hints for cacheable result families including `tools/list`: `ttlMs` expresses freshness lifetime and `cacheScope` expresses whether reuse is private or shareable. OriginWeave's first typed `tools/list` result therefore binds `resultType = complete`, chooses the conservative boundary `ttlMs = 0` and private scope, derives the page directly from the reviewed tool catalog, and emits no continuation cursor for the current fixed single-page catalog. These metadata choices do not grant tool authority and do not claim JSON-RPC serialization, transport caching, OAuth, or a general pagination implementation. - Primary sources: Model Context Protocol, *2026-07-28 Specification* and the maintainers' official release announcement. ## Provenance standards @@ -50,15 +52,14 @@ The main [`docs/doctoring.md`](../doctoring.md) records the stable W3C PROV-O Re ## Product consequences -1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. -2. Pin exact Chromium/CDP compatibility evidence at release time. -3. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. -4. Keep WebMCP experimental/optional and propagate untrusted-content semantics. -5. Keep MCP browser state application-level rather than equating protocol transport/session metadata with browser authority. -6. Require modern MCP per-request protocol version and client capabilities from request `_meta`; on Streamable HTTP require the matching protocol-version header and exact method routing, while treating optional client identity metadata as non-authoritative. -7. Bind mandatory MCP result disposition and cacheable-list metadata to reviewed typed results; use a complete terminal result with zero freshness and private scope unless a separate reviewed policy proves broader semantics safe. Reject a `tools/list` cursor while the current fixed page has never issued one. -8. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. -9. Treat WARC/PROV as provenance representations, not policy or truth escalation. +1. Version adapter contracts independently from OriginWeave session/context/action/evidence types. Admit a BiDi `script.NodeRemoteValue` only as an untrusted transport handle when its type is exactly `node` and a usable control-free `sharedId` is present; do not treat a realm-local `handle`, a missing shared identifier, or control/whitespace-bearing protocol text as OriginWeave node authority. Treat an accessibility-query role as one exact WAI-ARIA token, not a whitespace-separated fallback list. +2. Serialize reviewed BiDi commands from already validated bounded values only, then correlate each non-null response id to the exact consumed command before payload admission; a protocol-shaped JSON envelope or matching id never substitutes for authenticated browser transport, current session/context/origin/document authority, policy authorization, or post-condition evidence. +3. Pin exact Chromium/CDP compatibility evidence at release time. +4. Keep WebDriver BiDi's Working Draft status visible in compatibility claims. +5. Keep WebMCP experimental/optional and propagate untrusted-content semantics. +6. Keep MCP browser state application-level rather than equating protocol transport/session metadata with browser authority. +7. Test Manifest V3 compatibility and extension-to-Agent authority isolation as separate evidence classes. +8. Treat WARC/PROV as provenance representations, not policy or truth escalation. ## References — APA 7th @@ -80,6 +81,12 @@ Model Context Protocol. (2026). *Model Context Protocol specification (2026-07-2 World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ +World Wide Web Consortium. (2023, June 6). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/2023/REC-wai-aria-1.2-20230606/ + World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ -International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html \ No newline at end of file +World Wide Web Consortium. (2026, July 20). *WebDriver BiDi* (Editor’s Draft). https://w3c.github.io/webdriver-bidi/ + +World Wide Web Consortium. (2026, August 5). *Accessible name and description computation 1.2* (W3C Working Draft). https://www.w3.org/TR/2026/WD-accname-1.2-20260805/ + +International Organization for Standardization. (2017). *Information and documentation—WARC file format* (ISO Standard No. 28500:2017). https://www.iso.org/standard/68004.html diff --git a/docs/doctoring/rust-toolchain-freshness.md b/docs/doctoring/rust-toolchain-freshness.md deleted file mode 100644 index a00e7fb08..000000000 --- a/docs/doctoring/rust-toolchain-freshness.md +++ /dev/null @@ -1,44 +0,0 @@ -# Rust toolchain freshness and reproducibility - -## Decision - -OriginWeave keeps Rust `1.97.1` as the exact stable compiler baseline. As of -2026-08-19 this is the current stable point release, so the generic compiler -suggestion to upgrade does not justify replacing it with a floating `stable` -channel. - -Production line, region, and function coverage remains on the stable compiler. -Branch coverage uses the independently date-pinned `nightly-2026-08-18` -toolchain because upstream `cargo-llvm-cov` still identifies Rust branch -coverage as unstable and nightly-only. Every branch-coverage command must use -the same date pin, and exact-head CI must prove that `llvm-tools-preview`, the -pinned `cargo-llvm-cov` release, the workspace, and the coverage verifier remain -compatible before merge. - -The root `rust-toolchain.toml` is tracked through GitHub Dependabot's -`rust-toolchain` ecosystem. Toolchain changes therefore arrive as reviewable -pull requests rather than silently changing underneath local or CI builds. -Date-pinned branch-coverage nightly updates remain explicit infrastructure -changes and must preserve the repository contract test. - -## Failure interpretation - -The historical OriginWeave coverage failure at PR #192 predecessor head -`ccb7d31dfe7654bab800d463c2391cc1a19c7d74` was not proof that the compiler was -too old. The compiler emitted the generic note while rejecting a non-stable -const conversion in test code. The current PR #192 head moved that conversion -out of a constant and passed the complete native CI workflow. Toolchain -freshness and source compatibility are therefore maintained as separate -controls. - -## References - -GitHub. (2025, August 19). *Dependabot now supports Rust toolchain updates*. -GitHub Changelog. -https://github.blog/changelog/2025-08-19-dependabot-now-supports-rust-toolchain-updates/ - -Rust Project Developers. (2026, July 16). *Announcing Rust 1.97.1*. Rust Blog. -https://blog.rust-lang.org/2026/07/16/Rust-1.97.1/ - -Taiki Endo and contributors. (2026). *cargo-llvm-cov* (Version 0.8.6) -[Computer software]. GitHub. https://github.com/taiki-e/cargo-llvm-cov diff --git a/docs/product-roadmap.md b/docs/product-roadmap.md index c61dfee63..4aa0ace97 100644 --- a/docs/product-roadmap.md +++ b/docs/product-roadmap.md @@ -70,6 +70,7 @@ Delivered document-node authority foundation: - a nonzero `BrowsingContextId` for one independently navigable browser context inside that session; - a nonzero `DocumentEpoch` identity for one observed document lifetime inside that context; - an `ObservedNodeHandle` bound to the exact browser session, browsing context, canonical origin, document epoch, and nonzero adapter-local node identifier; +- same-call QueryNodes admission that transfers a SemanticObservation protocol-use proof by ownership into `bind_current_nodes` before translating admitted `locateNodes` `sharedId` values into those handles; - deterministic rejection of cross-session, cross-context, cross-origin, or stale-document node reuse before a future browser adapter performs an action; - reusable core contracts without Chromium, WebDriver, selector, script-execution, network, storage, or secret dependencies. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md deleted file mode 100644 index 8a702c75f..000000000 --- a/docs/product-technical-gap-baseline.md +++ /dev/null @@ -1,346 +0,0 @@ -# Product and Technical Gap Baseline - -This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, architecture decisions, or live GitHub state. It keeps buyer-visible gaps, current issues, active pull-request evidence, and commercial completion tracks in one discoverable place. Protected `main` is the implementation boundary: code in an open pull request is not shipped behavior. - -## Observed snapshot: 2026-08-26 - -### Protected-main truth - -- Protected `main` is at `b05d5acca82b9d916ada2c8e82f59f92a89817e1` for this snapshot. Since the 2026-08-24 observation (`0841d2ab`), protected `main` absorbed #196 (dated gap baseline publication), #216 (RFC 3986 evidence-path syntax enforcement), #194 (branch-coverage nightly and toolchain tracking refresh), #168 (typed MCP stateless tool-routing foundations), and #151 (exact crash-root termination before crash credit). -- Phase 0 remains complete as a reusable safety-kernel foundation: typed policy contracts, destination classification, direct TCP peer verification, TLS service identity, evidence bounds, resource mitigation, document-node authority, and protected-main tests. -- Phase 1 is **in progress**, not shipped. The first real Chromium vertical slice still needs the active WebDriver BiDi transport stack to reach protected `main`, then compose isolated Chromium launch, session/context identity, semantic observation, typed action authorization, native browser input, post-condition proof, evidence, cancellation, crash recovery, and profile/process teardown. -- HTTP/1.1 bounds, downloads/MIME, proxy/PAC consumption, full browser-network integration, the sensitive-data broker runtime, durable WARC/PROV capture, persistent task/API surfaces, signed cross-platform distribution, enterprise administration, and release-grade buyer acceptance remain open. -- Active pull requests remain evidence, not shipped behavior. Successful checks on a feature or stacked branch do not prove that protected `main` contains the capability or that a child can merge before its prerequisite. - -### Open pull requests - -The live repository contained **126 open pull requests: 54 non-draft and 72 draft** when this snapshot re-paginated the complete open inventory. Compared with the prior **2026-08-24 158-PR snapshot**, the current inventory is 32 PRs smaller. Intervening queue consolidation includes #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 being merged into their immediate stacked prerequisites, while PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review. Those transitions are queue consolidation, not protected-main delivery; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`, with 13 open issues and no releases or tags. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. - -#### 2026-08-26 maintenance-loop record - -The interactive maintenance loop performed the following verified state changes on exact heads; none of them is protected-main behavior until merged: - -| Action | Exact evidence | -|---|---| -| Supersession closure | #153 closed with replacement evidence: base-stack tip (`4da223ac`) already implements `_terminate_owned_process_bounded` exit-race tolerance that supersedes the branch delta | -| Conflict reconciliation | Merge commits pushed to #37 (`27f6acd6`, ci.yml aligned to reviewed `nightly-2026-08-18` pin), #149 (`7852a540` + rustfmt fix `54f96008`), #152 (`65b0c705`), #173 (`ecc9574a`), #175 (`765c88f6`, keeps `crate_root.rs` naming) | -| Governance remediation (#212) | #43 reconciled with main in `04e262d5`; the `chrome_sandbox` workflow mutation was first removed, then restored under recorded independent authorization (issue #212 option (b)) because the PR's own contract test fails closed without it; fresh exact-head checks re-ran on the restored head | -| Security finding fix (#124) | Strix vuln-0001 (Unicode homoglyph path confusion, MEDIUM) remediated in `30cc458b`: audited workflow paths now restricted to a canonical ASCII alphabet with homoglyph/fraction-slash/fullwidth regression contract tests; CHANGELOG updated | -| Fail-closed provider re-dispatch | ~21 failed Strix required-check runs re-dispatched on unchanged exact heads; completed reruns returned success on #46, #48, #156, #157, #159, #218, and #219 heads at snapshot time; cancellations only where newer heads superseded the run | -| Current-head review re-dispatch | Central merge-scheduler dispatches sent for #47, #62, #63, #65, #74, #166, #173, #175, and #220 because their stale `CHANGES_REQUESTED` verdicts cited coverage-evidence results that are green on the same heads today | - -#### Organization review-pipeline congestion record - -Between 2026-08-26T02:44Z and 2026-08-26T03:35Z the organization-wide Actions queue exhibited a systemic backlog: scheduler, OpenCode-review-dispatch, Noema, and Strix runs across `.github`, `naruon`, `pg-erd-cloud`, and OriginWeave sat `queued`/`pending` while only single-digit runs were `in_progress`. This delays every current-head AI review and therefore every ruleset-gated merge. It is an infrastructure-capacity signal, not a code defect, and it does not authorize merging without current-head review evidence. - -Representative active workstreams at this snapshot were: - -| Workstream | Representative active PR evidence | Delivery boundary | -|---|---|---| -| Product baseline | (merged: #196 on 2026-08-24) | Baseline publication reached protected `main`; this document is its successor snapshot | -| Presentation identity | #229 at `585a7d5545b13f18d76f79100ff4d47ac423e861` onto `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | Ready/non-draft local privacy kernel; all observed exact-head checks except Strix passed, but the PR remains blocked and review-required, and no Chromium adapter or protected-main shipment is claimed | -| Enterprise approval authority | #220 | Ready/non-draft bounded maker-checker approval lifecycle on the exact `ApprovalScope`; all current-head checks green at snapshot, awaiting current-head review evidence | -| Release artifact identity | #218 and #219 | Ready/non-draft fail-closed benchmark release decision and canonical release manifest binding; Strix provider-failure reruns completed green on both heads | -| Schema-bound extraction and BAP lifecycle | #209 and #208 | Ready/non-draft schema-bound extraction contract and resumable task-lifecycle kernel; #209 Strix rerun green, #208 rerun re-dispatched after a further provider failure | -| WebDriver BiDi transport | #188 through #205 | Active stack whose top #205 merged into its prerequisite branch, not protected `main`; it exercises framed `locateNodes` exchange over a bounded WebSocket opening path, but authenticated browser-process provenance, semantic task execution, and protected-main shipment remain unproven | -| MCP adapter | (#168 merged) and #170 | Typed MCP routing foundations are protected-main behavior since 2026-08-24; conservative `tools/list` cache metadata remains active-PR evidence with a Strix rerun in flight | -| Workflow-registry audit | #124 | Real Strix finding vuln-0001 (Unicode homoglyph path confusion, MEDIUM) remediated on head `30cc458b` with regression contract tests; fresh exact-head checks and review re-running | -| Controlled Chromium and recovery | #65, #70-#73, #100, #105, #142-#152 and descendants | Real pinned-browser fixture, semantic location, resource, crash, and teardown evidence exists on active stacks; evidence does not transfer across heads or prerequisites | -| Durable WARC/PROV evidence | #210, #217 | Bounded WARC resource records and PROV JSON-LD binding are draft active-PR foundations; durable ownership, replay, retention/deletion, and browser side-effect reconciliation remain open | -| Manifest V3 and native messaging | #27, #43 governance remediation, and the extension/native-host stack including #154 and #169 | Compatibility and Agent-authority isolation remain incomplete until exact release artifacts and platform matrices are proven; #43's sandbox workflow mutation is now owner-authorized under issue #212 option (b) | -| Sensitive-data and model route policy | #10 and its active policy stacks | Deterministic policy values exist, but trusted broker execution, retention/deletion, runtime isolation, and auditable product workflows remain open | -| VPN/profile intent | #149 | Bounded WireGuard/IKEv2 profile authority reconciled with main (`54f96008`); it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof | - -PR #205 head `f427aa69151987d7e3369bd96d5739ea38d0f7ad` merged as `6c5ef5e2079d54c617183ecfa757e406f48f0aea` into stacked prerequisite branch `feat/webdriver-bidi-websocket-frame-transport` at base `c1bc7e78f3a9debf4f517fb6b5f11dd67be4ad92`. Its successful exact-head checks are stacked-branch integration evidence only; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`. - -#### Current exact-head active PR evidence - -The following newest slices were re-fetched from GitHub for this snapshot. Their exact base/head pairs are recorded so later checks, reviews, and restacks cannot be confused with predecessor evidence: - -| PR | State | Exact base head | Exact head | -|---|---|---|---| -| #220 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e0740a6f3a41067a4460249378e0266815018a74` | -| #219 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `3e34a54ae279686a28309d59b8b3b9bfbd283a80` | -| #218 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `911ea33d8a5aca7673307bb6fdcad4b450f5c111` | -| #209 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `b35d739017aa5d361b605be48045be50b5a35f6f` | -| #208 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e41d3be4c290c4e434aac33d777e511dfb94e03d` | -| #124 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `296ad25bb541023dbc869ae07ae1d853820f83a4` | - -These rows are delivery evidence only. None has counted independent approval in the current collaborator inventory, and predecessor rows from earlier snapshots are retained below as regression anchors that must never be promoted to current-head evidence. - -#### Regression-anchor exact-head evidence: superseded 2026-08-24 rows - -The following rows were current on 2026-08-24 and are retained only as regression anchors; every listed head has since been superseded or merged and must never be promoted to current-head evidence: - -| PR | State | Exact base head | Exact head | -|---|---|---|---| -| #222 | Draft | `56fcfa56525e4f2e980e0ee05b6776d621bcddc5` | `1e2ce3d4071a1a75ee891bdcd71c506b3b50d4bc` | -| #221 | Draft | `8145d40f1b028a8f4dc7e7da47ac89bb9e5bb2c7` | `6f339df1e5b3ddb265f4ddd7b262d4de1e0b5e1f` | -| #220 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `ed4cab16cf88c76ce1c145a22d0a274ef2d57263` | -| #219 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `8145d40f1b028a8f4dc7e7da47ac89bb9e5bb2c7` | -| #218 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `49e98fba6974219b3bb0336c822b12667f1e1c03` | -| #217 | Draft | `529d11a3571f6b1834b9baa49ef67eb08f043978` | `56fcfa56525e4f2e980e0ee05b6776d621bcddc5` | -| #216 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `75130851a0f7ce528a7a36382eb026ac7942a0aa` | -| #214 | Draft | `40d642d5470a7753b8211907c190367f742f2f12` | `f79999681866ecf0e5fe17d895170f3f6cae7361` | -| #211 | Draft | `85cc477688246900697f4cfb91c0c8f1f692934a` | `40d642d5470a7753b8211907c190367f742f2f12` | -| #210 | Draft | `c38b9665774d6b3754e572bed527737b5e179833` | `529d11a3571f6b1834b9baa49ef67eb08f043978` | -| #209 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `c38b9665774d6b3754e572bed527737b5e179833` | -| #208 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `85cc477688246900697f4cfb91c0c8f1f692934a` | - -The stack topology shows #209 → #210 → #217 → #222 (WARC/PROV chain), #208 → #211 → #214 (BAP chain), #218 → #221 → #220 (release/enterprise chain) at this snapshot. Every row above remains active-PR evidence; none is protected-main behavior. - -### Required-check provider failure record - -On 2026-08-23 the required Strix security scan failed closed on exact heads of #220 (`ed4cab16…`), #218 (`49e98fba…`), and #208 (`85cc4776…`) because its LLM provider/backend was unavailable (rate limit, token cap, connection, warm-up, or model-behavior failure); no vulnerability report artifact was produced, so the workflow correctly refused to convert an incomplete scan into passing security evidence. Failed jobs were re-dispatched on the unchanged exact heads on 2026-08-24 and again on 2026-08-26. This is a provider-infrastructure failure record, not a weakening of the fail-closed gate or a substitute for a completed authoritative scan. - -On 2026-08-26 rerun outcomes were verified per run: completed reruns returned `success` on the heads of #46, #48, #156, #157, #159, #218, and #219; several earlier runs for #37, #43, and #149 were cancelled only because conflict-reconciliation pushes created newer heads with fresh scans; remaining reruns were still in flight at snapshot time. One rerun (#124) produced a real MEDIUM finding (vuln-0001) instead of provider noise; that finding was remediated on the branch head rather than suppressed, preserving the fail-closed contract. - -#### #195/#198 WebDriver BiDi opening path status - -Phase 1 is **in progress**, not shipped. #195 and #198 provide bounded WebSocket opening-path evidence on active branches; framed BiDi commands, authenticated browser-process provenance, semantic task execution, and protected-main integration remain open. - -#### #149 VPN/profile intent status - -PR #149 is a ready (non-draft) pull request whose conflict reconciliation and rustfmt correction landed on head `54f96008` on 2026-08-26; it still only describes bounded WireGuard/IKEv2 profile authority and does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof. - -The current queue must be processed in dependency order. A green child branch cannot substitute for current checks and review on its prerequisite, synthetic merge, or eventual protected-main commit. PRs that only duplicate, supersede, or preserve stale branch topology should be closed with explicit replacement evidence rather than retained indefinitely; this loop exercised that policy by closing superseded #153 with replacement evidence. - -### Review and merge authority - -The active `CWL Central required workflows` ruleset (re-fetched for this snapshot) requires one approving review, resolved review threads, no last-push approval requirement, `merge`/`squash` merge methods, and seven configured required workflows (`close-empty-pr`, `opencode-review`, `pr-review-merge-scheduler`, `security-scan`, `strix`, `sast-semgrep`, `noema-review`). The current collaborator inventory contains only `seonghobae` with administration and push permissions, creating a **reviewer-provisioning gap** for counted non-author approval. - -This gap does not authorize self-approval, stale-head merges, administrative bypass, or weaker checks. Because the current GitHub ruleset independently requires a counted approval, the solo-maintainer hold does not satisfy the live merge gate: an eligible non-author collaborator must submit a formal `APPROVED` review on the current head. Until that reviewer-provisioning gap is repaired, protected-main merges stop even when exact-head checks, security gates, complete coverage, rustdoc/Clippy, threads, and AI-review evidence are otherwise complete. Before any merge decision, re-fetch the exact ruleset, collaborators, PR head/base, reviews, unresolved threads, and required checks; do not assume this dated observation remains current. - -### Open issues and operational signals - -| Issue | Current gap or signal | -|---|---| -| #28 | First real Chromium Agent Task vertical slice; highest immediate Phase 1 buyer-visible gap | -| #27 | Complete Manifest V3 compatibility and extension-authority isolation matrix | -| #9 | Bounded HTTP/1.1 semantics over the authenticated TLS stream | -| #10 | Purpose-bound operational PII disclosure and trusted broker/storage lifecycle | -| #123 | Fleet incident: disable orphaned TLS, HTTP, and one-shot workflow identities | -| #187 | Manual-authority review of the coverage-diagnostics workflow delta | -| #212 | Governance: remove or independently authorize the PR #43 MV3 workflow mutation — **option (b) executed 2026-08-26** with owner-directed authorization recorded on the issue and the mutation restored on the reconciled branch; re-evaluate if the authorization record is contested | -| #215 | Governance: restore an enforceable protected-main policy that does not create a routine admin bypass | -| #199 | Schema-bound extraction with durable WARC/PROV replay, retention, deletion, and offline verification | -| #200 | Stable BAP/MCP runtime API with authenticated, idempotent, cancellable, resumable task lifecycle | -| #201 | Signed cross-platform Chromium distribution, installer/updater, patch SLA, rollback, SBOM, and provenance | -| #202 | Enterprise control and experience plane: operator UI, Keyverse-compatible identity, tenancy, approval, audit, SLO, Figma, and Storybook | -| #203 | Release-grade web-agent benchmark and commercial acceptance gate bound to exact signed artifacts | - -Issue #206 (harden-runner custom detection initialization failure) was closed after its remediation landed on protected `main` between snapshots. - -The five newly separated product-completion tracks are **durable WARC/PROV replay**, **stable BAP/MCP runtime API**, **signed cross-platform Chromium distribution**, **enterprise control and experience plane**, and the **commercial acceptance gate**. They are separate issues because each has a distinct authority, data, release, and buyer-acceptance boundary. - -The hourly product-development loop is operational infrastructure, not proof that a browser product, issue, pull request, or release meets buyer acceptance. - -## Buyer-visible and technical gap matrix - -| Priority | Buyer-visible outcome | Protected-main status | Completion issue and acceptance evidence | -|---|---|---|---| -| P0 | A bounded task observes a real Chromium page, performs one typed action, verifies the post-condition, and emits provenance | **Open / Phase 1** | #28; repeated real Chromium E2E with isolated context, exact session/node authority, typed dispatch, post-condition, crash cleanup, and protected-main checks | -| P0 | Navigation consumes approved origin, resolution, route, TCP peer, TLS identity, bounded HTTP, redirect, MIME, and download policy | **Partial foundation** | #9 plus #28; real browser-network adapter proves the governed path is consumed end to end | -| P1 | Existing Chromium extensions remain compatible while Agent authority stays separate | **Partial active-PR evidence** | #27; exact supported-build/platform compatibility matrix, managed allow-list, native-host isolation, repeatability, and release binding | -| P1 | Authorized work can use necessary PII without ambient exposure | **Policy foundation; runtime open** | #10; opaque broker, exact field/purpose/destination/model policy, atomic use/revocation, retention/deletion, and value-free telemetry | -| P1 | Every released structured field is traceable to replayable source evidence | **Foundations only** | #199; durable WARC/PROV replay, integrity, retention, deletion, offline verification, extraction precision/recall, and 100% provenance completeness | -| P1 | External Agents integrate through a stable, authenticated product contract | **Partial active-PR MCP primitives** | #200; BAP 1.0, MCP 2026-07-28 adapter, idempotency, task cancellation/resume, checkpoint/reconciliation, and SDK conformance | -| P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | -| P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | -| P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | -| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 126-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | - -## Commercial completion definition - -OriginWeave is not complete merely because every low-level primitive exists in some open branch. A release candidate is commercially complete only when all of the following are true for the declared support profile: - -1. #9, #10, #27, and #28 are integrated on protected `main` as a complete browser/network/action/evidence chain. -2. #199 provides replayable, retention-governed evidence for every released structured result. -3. #200 exposes a stable authenticated runtime API and task lifecycle without raw Chromium authority leakage. -4. #201 produces signed, updateable, rollback-capable release artifacts bound to Chromium, SBOM, and provenance. -5. #202 supplies tenant-safe enterprise administration, approvals, audit, SLOs, incident recovery, accessible Figma/Storybook-backed UX, and control evidence. -6. #203 accepts the exact signed artifacts through a reproducible benchmark; missing or inconclusive evidence cannot be promoted to success. -7. Production function, line, region, and branch coverage and public API documentation remain exactly complete for OriginWeave-owned code. -8. CHANGELOG, version, supported-platform matrix, security policy, runbooks, licensing, release notes, upgrade/rollback guidance, and procurement evidence match the exact release. -9. No required check, browser/platform lane, security case, benchmark case, or independent review is skipped, stale, inherited, or represented by status-only evidence. -10. The open PR queue is reduced to bounded active work rather than being the only place where the product exists. - -## Next executable queue - -1. Drain the merge gate in dependency order: for every ready root PR whose current head is check-green with resolved threads, obtain the current ruleset's counted `APPROVED` review from an eligible non-author collaborator; OpenCode approval or skip evidence does not substitute for that GitHub review. If no eligible approver exists, record the reviewer-provisioning gap and do not merge. Root candidates include #37, #40, #43, #45–#48, #51, #62–#65, #74, #82, #124, #149, #152, #156–#166, #170, #173, #175, #208, #209, #218, and #219 as their re-dispatched checks land. Treat dependent children separately: only after a predecessor reaches protected `main`, retarget and independently revalidate its immediate child; preserve orders such as #218 → #221 → #220 rather than treating #208–#220 as a flat merge range. -2. Keep the organization review pipeline healthy: monitor the central Actions backlog recorded above; if OpenCode reviews stop landing on OriginWeave heads while the queue is idle, repair `ContextualWisdomLab/.github` dispatch/concurrency configuration rather than weakening any gate. -3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #181–#205 WebSocket opening path and framed BiDi command/response stack, then semantic observation, policy, action, post-condition, and recovery boundaries on protected `main`. -4. Finish #27 and #10 as separate security tracks; neither should be hidden inside the first browser PR. -5. Implement #199, then #200, so durable evidence and stable task authority precede broad enterprise integrations. -6. Implement #201 before making release/support claims; exact CI browser evidence must be bound to the actual signed artifact. -7. Design #202 in Figma, record the Figma File ID in the ADR, implement reusable design tokens and Storybook components, then add identity/tenant/approval/audit/operations integration. -8. Make #203 the final release gate across the exact signed distribution, not a source branch or model narrative. -9. Only after the commercial acceptance gate passes, increment the version, finalize CHANGELOG/release notes, publish signed artifacts, and verify upgrade/rollback from the prior supported release. - -## Evidence commands - -The volatile counts above are reproducible by paginating the complete open-PR inventory, flattening every page, and then inspecting each PR's exact head, checks, reviews, and review threads: - -```bash -set -euo pipefail -EVIDENCE_DIR="$(mktemp -d /tmp/originweave-evidence.XXXXXX)" -printf 'Evidence directory: %s\n' "$EVIDENCE_DIR" >&2 - -gh api --paginate --slurp 'repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100' \ - > "$EVIDENCE_DIR/open-pr-pages.json" -jq '[.[][]]' "$EVIDENCE_DIR/open-pr-pages.json" \ - > "$EVIDENCE_DIR/open-prs.json" -jq '{ - open_pull_requests: length, - non_draft: (map(select(.draft == false)) | length), - draft: (map(select(.draft == true)) | length) -}' "$EVIDENCE_DIR/open-prs.json" - -gh api 'repos/ContextualWisdomLab/OriginWeave/branches/main' \ - > "$EVIDENCE_DIR/main-branch.json" -gh api --paginate --slurp \ - 'repos/ContextualWisdomLab/OriginWeave/rules/branches/main?per_page=100' \ - > "$EVIDENCE_DIR/main-branch-rule-pages.json" -jq '[.[][]]' "$EVIDENCE_DIR/main-branch-rule-pages.json" \ - > "$EVIDENCE_DIR/main-branch-rules.json" -gh api --paginate --slurp \ - 'repos/ContextualWisdomLab/OriginWeave/collaborators?affiliation=all&per_page=100' \ - > "$EVIDENCE_DIR/collaborator-pages.json" -jq '[.[][]]' "$EVIDENCE_DIR/collaborator-pages.json" \ - > "$EVIDENCE_DIR/collaborators.json" - -jq -r '.[].number' "$EVIDENCE_DIR/open-prs.json" | while read -r PR; do - STABLE_HEAD=false - for ATTEMPT in 1 2 3; do - VERDICT_PATH="$EVIDENCE_DIR/pr-${PR}-merge-verdict.json" - VERDICT_TMP="$EVIDENCE_DIR/pr-${PR}-merge-verdict.json.tmp" - rm -f "$VERDICT_PATH" "$VERDICT_TMP" "$EVIDENCE_DIR/pr-${PR}-rechecked.json" - PR_JSON="$EVIDENCE_DIR/pr-${PR}.json" - gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" > "$PR_JSON" - HEAD_SHA=$(jq -r '.head.sha' "$PR_JSON") - BASE_SHA=$(jq -r '.base.sha' "$PR_JSON") - - gh api --paginate --slurp \ - "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100" \ - > "$EVIDENCE_DIR/pr-${PR}-check-runs.json" - gh api --paginate --slurp \ - "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/statuses?per_page=100" \ - > "$EVIDENCE_DIR/pr-${PR}-statuses.json" - gh api --paginate --slurp \ - "repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100" \ - > "$EVIDENCE_DIR/pr-${PR}-reviews.json" - gh api --paginate --slurp \ - "repos/ContextualWisdomLab/OriginWeave/actions/runs?head_sha=$HEAD_SHA&per_page=100" \ - > "$EVIDENCE_DIR/pr-${PR}-workflow-runs.json" - gh api graphql --paginate --slurp \ - -F owner=ContextualWisdomLab \ - -F name=OriginWeave \ - -F number="$PR" \ - -f query=' -query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - reviewThreads(first: 100, after: $endCursor) { - nodes { id isResolved isOutdated } - pageInfo { hasNextPage endCursor } - } - } - } -}' > "$EVIDENCE_DIR/pr-${PR}-review-threads.json" - - jq -n \ - --arg head "$HEAD_SHA" \ - --slurpfile pr "$PR_JSON" \ - --slurpfile checks "$EVIDENCE_DIR/pr-${PR}-check-runs.json" \ - --slurpfile statuses "$EVIDENCE_DIR/pr-${PR}-statuses.json" \ - --slurpfile reviews "$EVIDENCE_DIR/pr-${PR}-reviews.json" \ - --slurpfile workflow_runs "$EVIDENCE_DIR/pr-${PR}-workflow-runs.json" \ - --slurpfile rules "$EVIDENCE_DIR/main-branch-rules.json" \ - --slurpfile collaborators "$EVIDENCE_DIR/collaborators.json" \ - --slurpfile threads "$EVIDENCE_DIR/pr-${PR}-review-threads.json" \ - --arg base "$BASE_SHA" \ - '( - [ - $rules[][]? - | select(.type == "pull_request") - | .parameters - ] | first // {} - ) as $pull_request_parameters - | ( - [ - $reviews[][][]? - | {reviewer: .user.login, state, submitted_at, commit_id} - | select(.submitted_at != null) - | select(.reviewer != $pr[0].user.login) - | select(.reviewer as $reviewer | - any($collaborators[][]?; - .login == $reviewer and - (.permissions.push == true or - .permissions.maintain == true or - .permissions.admin == true))) - ] - | group_by(.reviewer) - | map(sort_by(.submitted_at) | last) - | map(select(.state == "APPROVED" and .commit_id == $head)) - ) as $current_approvals - | ($pull_request_parameters.required_approving_review_count // 0) as $required_review_count - | ($pull_request_parameters.require_last_push_approval // false) as $require_last_push_approval - | { - head_sha: $head, - base_sha: $base, - required_status_checks: { - check_runs: [$checks[][].check_runs[]?], - legacy_statuses: [$statuses[][][]?] - }, - workflow_runs: [$workflow_runs[][].workflow_runs[]?], - counted_approvals: ($current_approvals | length), - required_approving_review_count: $required_review_count, - require_last_push_approval: $require_last_push_approval, - last_push_approval_authority: ( - if $require_last_push_approval == true - then "github_rule_evaluation_required" - else "not_required" - end - ), - approval_gate_satisfied: ( - if $pull_request_parameters.require_last_push_approval == true then false - else (($current_approvals | length) >= $required_review_count) - end - ), - required_workflows: [ - $rules[][]? - | select(.type == "workflows") - | .parameters.workflows[] - ], - unresolved_threads: [ - $threads[][].data.repository.pullRequest.reviewThreads.nodes[]? - | select(.isResolved == false and .isOutdated == false) - ] - }' > "$VERDICT_TMP" - - RECHECKED_PR_JSON="$EVIDENCE_DIR/pr-${PR}-rechecked.json" - RECHECKED_HEAD_SHA=$(gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" \ - | tee "$RECHECKED_PR_JSON" \ - | jq -r '.head.sha') - RECHECKED_BASE_SHA=$(jq -r '.base.sha' "$RECHECKED_PR_JSON") - if [[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" && "$RECHECKED_BASE_SHA" == "$BASE_SHA" ]]; then - mv "$VERDICT_TMP" "$VERDICT_PATH" - mv "$RECHECKED_PR_JSON" "$PR_JSON" - STABLE_HEAD=true - break - fi - rm -f "$VERDICT_TMP" "$RECHECKED_PR_JSON" - printf 'Discarding moving head/base evidence for PR #%s (head %s -> %s, base %s -> %s) and retrying.\n' \ - "$PR" "$HEAD_SHA" "$RECHECKED_HEAD_SHA" "$BASE_SHA" "$RECHECKED_BASE_SHA" >&2 - done - if [[ "$STABLE_HEAD" != true ]]; then - rm -f "$EVIDENCE_DIR"/pr-${PR}-*.json - printf 'Unable to collect stable exact-head/base evidence for PR #%s after 3 attempts.\n' "$PR" >&2 - exit 1 - fi -done -``` - -The branch-scoped rules response determines the active rules affecting `main`; each PR's exact `HEAD_SHA` then determines which check runs, legacy statuses, workflow runs, reviews, and unresolved threads are current. The saved merge verdict binds counted approvals to the latest review per eligible collaborator, excludes the PR author, and requires `APPROVED` on the exact head. It deliberately does **not** infer GitHub's actual last-push actor from commit author or committer metadata: when `require_last_push_approval` is active, this portable evidence procedure records `github_rule_evaluation_required` and keeps `approval_gate_satisfied` false until GitHub's authoritative rule evaluation is consulted. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. Evidence is retained only when both `RECHECKED_HEAD_SHA` and `RECHECKED_BASE_SHA` match the collected values; a moving head or base discards the temporary verdict, and three failed attempts leave no unstable merge verdict. - -For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. diff --git a/docs/traceability/extension-authority-security.md b/docs/traceability/extension-authority-security.md index 1c211f83d..a36380a31 100644 --- a/docs/traceability/extension-authority-security.md +++ b/docs/traceability/extension-authority-security.md @@ -50,12 +50,6 @@ Exact head `e83749acd1cf5a0b778ba38eb9d6ed5a9bd1e68f` deliberately keeps only th The exact head has successful CI, exact owned production coverage, Security Scan, SAST and CodeRabbit status and is Ready for review. It has no raw secret bytes and does not create approval evidence, a broker, browser-fill adapter, protected-value store, KMS path, authenticated workload identity, persistence owner, or release claim. -### Origin-bound extension grant evaluation - -**Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` - -The current origin-binding slice requires `ExtensionAgentGrant` and `ExtensionAccessRequest` to carry the same canonical origin. A same-session, same-context request for `https://other.example` or `https://app.example:8443` against a grant for `https://app.example` is `DenyOriginMismatch`. Exclusive trusted-time expiry is evaluated after that origin match: `now >= expires_at` is `DenyExpired`. This does not install an extension, parse Chrome messages, bind task identity, or mint Agent capabilities from Manifest V3 permissions. - ## 4. Security interpretation The executable authority chain is intentionally non-transitive: diff --git a/docs/traceability/mcp-authority-route.md b/docs/traceability/mcp-authority-route.md deleted file mode 100644 index 94f181ed4..000000000 --- a/docs/traceability/mcp-authority-route.md +++ /dev/null @@ -1,58 +0,0 @@ -# MCP 2026-07-28 authority-route traceability - -- **`tools/call` capability maturity:** `IMPLEMENTED_ON_PROTECTED_MAIN` -- **`tools/list` capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` -- **Protected-main owning work:** merged PR #168 `feat(mcp): bind stateless tool routing to typed actions` -- **Active follow-on:** PR #170 `feat(mcp): expose conservative tools list cache contract` -- **Complete MCP adapter status:** `PLANNED` -- **Governing decision:** ADR 0107 - -## Scope - -Protected main at `b05d5acca82b9d916ada2c8e82f59f92a89817e1` contains the bounded Rust control-plane foundation for MCP `2026-07-28` `tools/call` routing that merged through PR #168. It validates the represented stateless routing envelope, bounds and syntax-validates both attacker-controlled method fields and both attacker-controlled tool-name fields before correlation, maps only an explicit reviewed `originweave.*` catalog to existing typed `ActionKind` values, derives discovery metadata from the same catalog, and rejects route/action mismatch before ordinary deterministic policy evaluation. Methods are nonempty reviewed-ASCII routing tokens of at most 64 bytes; tool names are nonempty reviewed-ASCII identifiers of at most 128 bytes. Invalid method metadata is rejected distinctly from a bounded but unsupported MCP method. - -A successful `ValidatedMcpToolCall` proves routing integrity only. It grants no capability, origin, approval, secret, browser, tenant, persistence, network, or evidence authority. `originweave_policy::evaluate_mcp` still delegates to the ordinary policy evaluator after the route/action match. - -Active PR #170 builds on that protected-main catalog with a conservative typed `tools/list` request/result boundary. Its current branch requires matching MCP protocol metadata, required client-capability presence, bounded and syntax-validated routing/body methods, exact `tools/list` routing, and no caller-supplied cursor because the fixed catalog issues none. Its result is one complete page with zero freshness, private cache scope, and no continuation cursor. This active-PR slice remains non-shipped until it reaches protected main and does not grant any OriginWeave action authority. - -## Product-status reconciliation - -`docs/PRD.md` PRD-INT-004 and `docs/TRD.md` Section 12 intentionally remain **Planned** at the complete-adapter level. That status is not contradicted by the bounded `tools/call` foundation now on protected main or by active PR #170: both are reusable control-plane contracts below the complete product adapter. `README.md` and `CHANGELOG.md` distinguish protected-main routing from the active discovery refinement, and ADR 0107 records the protocol/version and authority boundary. - -The following remain outside protected main and PR #170 and must not be inferred from either: - -- Streamable HTTP transport parsing and header materialization; -- JSON-RPC/HTTP response serialization of the typed discovery page; -- OAuth and authenticated MCP deployment policy; -- browser-control I/O or BiDi/CDP/WebMCP translation; -- secret materialization or broker transport; -- persistence, durable audit storage, or WARC/PROV export; -- general pagination/subscription state beyond the fixed no-cursor catalog; and -- an OriginWeave Protocol version transition. - -## Version boundary - -The protected-main routing foundation and active discovery refinement accept only protocol generation `2026-07-28`. MCP versioning is independent of the OriginWeave Protocol. A later MCP revision does not silently change OriginWeave action, risk, capability, approval, secret, origin, tenant, browser, or evidence semantics. - -The reviewed primary source is: - -Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://modelcontextprotocol.io/specification/2026-07-28 - -The canonical bibliography remains `docs/doctoring.md`. - -## Executable evidence - -Protected-main PR #168 production/test surfaces include: - -- `crates/originweave-core/src/mcp.rs` — bounded deterministic catalog plus method/tool routing validation in the `ValidatedMcpToolCall` primitive; -- `crates/originweave-core/tests/mcp_authority_route.rs` — mapping, exact method/tool bounds, empty/oversized/malformed inputs, version/method/header-body correlation, and error-contract evidence; -- `crates/originweave-policy/src/lib.rs` — `evaluate_mcp` route/action guard before normal policy evaluation; and -- `crates/originweave-policy/tests/mcp_route_binding.rs` — confused-deputy and policy-preservation evidence. - -Active PR #170 additionally exercises its discovery contract in `crates/originweave-core/tests/mcp_tools_list_cache.rs`, including result/cache semantics, required protocol/client metadata, bounded protocol and method validation, routing correlation, cursor rejection, and public error contracts. - -Exact current-head CI/security/review evidence must be regenerated after every branch mutation. Protected-main evidence proves only the merged `tools/call` foundation; predecessor or protected-main results are not current-head proof for active PR #170. - -## Promotion rule - -The bounded `tools/call` routing foundation is already `IMPLEMENTED_ON_PROTECTED_MAIN`. The `tools/list` discovery refinement may change to `IMPLEMENTED_ON_PROTECTED_MAIN` only after PR #170 reaches protected `main` under live governance and exact-head acceptance. Neither promotion makes the complete MCP adapter implemented; each remaining transport/runtime boundary requires its own integrated evidence. diff --git a/tests/fixtures/agent_task_basic/index.html b/tests/fixtures/agent_task_basic/index.html deleted file mode 100644 index 510b239f1..000000000 --- a/tests/fixtures/agent_task_basic/index.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - OriginWeave controlled Agent Task fixture - - -
-

Controlled Agent Task

-

This page is synthetic test data for deterministic browser integration.

- -
- - - -
- - idle - - -
- - - - diff --git a/tests/test_agent_task_fixture_contract.py b/tests/test_agent_task_fixture_contract.py deleted file mode 100644 index 2565a35c7..000000000 --- a/tests/test_agent_task_fixture_contract.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Fail-first contract for the controlled Chromium Agent Task fixture.""" - -from __future__ import annotations - -from html.parser import HTMLParser -import pathlib -import unittest - -ROOT = pathlib.Path(__file__).resolve().parents[1] -FIXTURE = ROOT / "tests" / "fixtures" / "agent_task_basic" / "index.html" - - -def _is_credential_input(attributes: dict[str, str | None]) -> bool: - """Return whether parsed input attributes describe a credential surface.""" - - input_type = (attributes.get("type") or "").strip().lower() - if input_type == "password": - return True - - autocomplete = (attributes.get("autocomplete") or "").strip().lower() - autocomplete_tokens = autocomplete.split() - return any( - token == "one-time-code" or "password" in token - for token in autocomplete_tokens - ) - - -class _FixtureParser(HTMLParser): - """Collect the small semantic surface required by the deterministic fixture.""" - - def __init__(self) -> None: - super().__init__() - self.ids: set[str] = set() - self.labels_for: set[str] = set() - self.input_names: set[str] = set() - self.input_attributes: list[dict[str, str | None]] = [] - self.button_types: set[str] = set() - self.hidden_injection_markers = 0 - - def handle_starttag( - self, tag: str, attrs: list[tuple[str, str | None]] - ) -> None: - attributes = dict(attrs) - element_id = attributes.get("id") - if element_id: - self.ids.add(element_id) - if tag == "label" and attributes.get("for"): - self.labels_for.add(attributes["for"]) - if tag == "input": - self.input_attributes.append(attributes) - if attributes.get("name"): - self.input_names.add(attributes["name"]) - if tag == "button" and attributes.get("type"): - self.button_types.add(attributes["type"]) - if ( - attributes.get("data-originweave-untrusted") == "prompt-injection" - and "hidden" in attributes - and attributes.get("aria-hidden") == "true" - ): - self.hidden_injection_markers += 1 - - -class AgentTaskFixtureContractTests(unittest.TestCase): - """Require one deterministic semantic workflow for the first browser slice.""" - - def setUp(self) -> None: - """Load the checked-in fixture once for each independent contract.""" - - self.html = FIXTURE.read_text(encoding="utf-8") - self.parser = _FixtureParser() - self.parser.feed(self.html) - - def test_fixture_exposes_semantic_form_and_observable_post_condition(self) -> None: - """The fixture must support role/name discovery and a deterministic state change.""" - - self.assertIn("task-text", self.parser.ids) - self.assertIn("task-text", self.parser.labels_for) - self.assertIn("task_text", self.parser.input_names) - self.assertIn("submit", self.parser.button_types) - self.assertIn("task-result", self.parser.ids) - self.assertIn('data-state="idle"', self.html) - self.assertIn('result.dataset.state = "submitted"', self.html) - self.assertIn("result.textContent = taskText.value", self.html) - - def test_fixture_contains_explicit_untrusted_hidden_prompt_injection(self) -> None: - """A later real-browser regression needs hostile hidden page content to ignore.""" - - self.assertEqual(self.parser.hidden_injection_markers, 1) - self.assertIn("UNTRUSTED_PAGE_INSTRUCTION", self.html) - self.assertIn("request new browser capabilities", self.html) - - def test_hidden_injection_requires_the_actual_hidden_attribute(self) -> None: - """ARIA metadata alone must not satisfy the hidden-injection fixture contract.""" - - parser = _FixtureParser() - parser.feed( - "" - "" - ) - self.assertEqual(parser.hidden_injection_markers, 1) - - def test_fixture_is_synthetic_and_has_no_credential_fields(self) -> None: - """The controlled workflow must not require or imitate real secret collection.""" - - for attributes in self.parser.input_attributes: - with self.subTest(attributes=attributes): - self.assertFalse(_is_credential_input(attributes)) - - lowered = self.html.lower() - for forbidden in ("api_key", "secret_key"): - with self.subTest(forbidden=forbidden): - self.assertNotIn(forbidden, lowered) - - def test_credential_detection_is_quote_independent(self) -> None: - """Parsed credential semantics must reject single-quoted and tokenized forms.""" - - for html in ( - "", - "", - "", - "", - "", - ): - with self.subTest(html=html): - parser = _FixtureParser() - parser.feed(html) - self.assertEqual(len(parser.input_attributes), 1) - self.assertTrue(_is_credential_input(parser.input_attributes[0])) - - parser = _FixtureParser() - parser.feed("") - self.assertEqual(len(parser.input_attributes), 1) - self.assertFalse(_is_credential_input(parser.input_attributes[0])) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_doctoring_reference_contract.py b/tests/test_doctoring_reference_contract.py deleted file mode 100644 index bdeded44f..000000000 --- a/tests/test_doctoring_reference_contract.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Regression contracts for standards references that bind OriginWeave design claims.""" - -from __future__ import annotations - -import pathlib -import unittest - -ROOT = pathlib.Path(__file__).resolve().parents[1] -DOCTORING = ROOT / "docs" / "doctoring.md" - - -class DoctoringReferenceContractTests(unittest.TestCase): - """Keep cited primary-standard authorship aligned with the canonical source.""" - - def test_rfc_5280_reference_uses_canonical_author_initials(self) -> None: - """RFC 5280 must credit Sharon Boeyen as S. Boeyen, matching RFC Editor metadata.""" - text = DOCTORING.read_text(encoding="utf-8") - expected = ( - "Cooper, D., Santesson, S., Farrell, S., Boeyen, S., Housley, R., & Polk, W. " - "(2008). *Internet X.509 public key infrastructure certificate and certificate " - "revocation list (CRL) profile* (RFC 5280). Internet Engineering Task Force. " - "https://doi.org/10.17487/RFC5280" - ) - self.assertIn(expected, text) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_documentation_active_pr_evidence_contract.py b/tests/test_documentation_active_pr_evidence_contract.py index bc60535a2..d2a067e50 100644 --- a/tests/test_documentation_active_pr_evidence_contract.py +++ b/tests/test_documentation_active_pr_evidence_contract.py @@ -8,8 +8,6 @@ DOCS = ROOT / "docs" FITNESS = DOCS / "DOCUMENTATION_FITNESS.md" MATURITY = DOCS / "evidence" / "2026-08-10-active-pr-maturity.md" -BASELINE = DOCS / "product-technical-gap-baseline.md" -CHANGELOG = ROOT / "CHANGELOG.md" def active_pr_row(text: str, pr_number: int) -> str: @@ -30,33 +28,6 @@ class ActivePullRequestDocumentationContractTests(unittest.TestCase): def setUpClass(cls) -> None: cls.fitness = FITNESS.read_text(encoding="utf-8") cls.maturity = MATURITY.read_text(encoding="utf-8") - cls.baseline = BASELINE.read_text(encoding="utf-8") - cls.changelog = CHANGELOG.read_text(encoding="utf-8") - - def test_latest_live_pr_snapshot_is_recorded_in_the_product_baseline(self) -> None: - """The baseline must preserve exact heads for the newest active product slices.""" - for marker in ( - "Current exact-head active PR evidence", - "| #220 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e0740a6f3a41067a4460249378e0266815018a74` |", - "| #219 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `3e34a54ae279686a28309d59b8b3b9bfbd283a80` |", - "| #218 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `911ea33d8a5aca7673307bb6fdcad4b450f5c111` |", - "| #209 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `b35d739017aa5d361b605be48045be50b5a35f6f` |", - "| #208 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e41d3be4c290c4e434aac33d777e511dfb94e03d` |", - "| #124 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `296ad25bb541023dbc869ae07ae1d853820f83a4` |", - ): - with self.subTest(marker=marker): - self.assertIn(marker, self.baseline) - - def test_baseline_refresh_changelog_matches_the_live_snapshot(self) -> None: - """The changelog must classify and state the same baseline refresh.""" - refresh = "Refreshed the product and technical gap baseline with the current open-PR inventory" - added = self.changelog.split("### Added", 1)[1].split("### Changed", 1)[0] - changed = self.changelog.split("### Changed", 1)[1].split("### Security", 1)[0] - self.assertIn(refresh, added) - self.assertNotIn(refresh, changed) - self.assertIn("126 open pull requests (54 ready, 72 draft)", self.changelog) - self.assertNotIn("128 open pull requests (54 ready, 74 draft)", added) - self.assertNotIn("153 open pull requests (39 ready, 114 draft)", added) def test_dependency_stacks_are_explicit_and_non_shipped(self) -> None: """Current browser, network, sensitive and compatibility stacks stay active-only.""" diff --git a/tests/test_gap_snapshot_inventory_consistency.py b/tests/test_gap_snapshot_inventory_consistency.py deleted file mode 100644 index 0daca1f85..000000000 --- a/tests/test_gap_snapshot_inventory_consistency.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Regression contracts for the current dated product-gap inventory snapshot.""" - -from __future__ import annotations - -from pathlib import Path -import unittest - - -ROOT = Path(__file__).resolve().parents[1] -BASELINE = ROOT / "docs" / "product-technical-gap-baseline.md" -CHANGELOG = ROOT / "CHANGELOG.md" - - -class GapSnapshotInventoryConsistencyTests(unittest.TestCase): - """Prevent one dated snapshot from carrying contradictory live PR totals.""" - - @classmethod - def setUpClass(cls) -> None: - cls.baseline = BASELINE.read_text(encoding="utf-8") - cls.changelog = CHANGELOG.read_text(encoding="utf-8") - - def test_current_baseline_inventory_matches_the_verified_snapshot(self) -> None: - """The current snapshot must use the exact 126/54/72 inventory observation.""" - current = self.baseline.split("### Open pull requests", 1)[1].split( - "#### 2026-08-26 maintenance-loop record", 1 - )[0] - for marker in ( - "126 open pull requests", - "54 non-draft", - "72 draft", - ): - with self.subTest(marker=marker): - self.assertIn(marker, current) - - for stale in ( - "128 open pull requests", - "74 draft", - "153 open pull requests", - "114 draft", - ): - with self.subTest(stale=stale): - self.assertNotIn(stale, current) - - def test_unreleased_changelog_uses_one_current_inventory(self) -> None: - """The Unreleased current snapshot must agree before and inside Added.""" - unreleased = self.changelog.split("## [Unreleased]", 1)[1] - preamble, remainder = unreleased.split("### Added", 1) - added = remainder.split("### Changed", 1)[0] - - expected = "126 open pull requests (54 ready, 72 draft)" - self.assertIn(expected, preamble) - self.assertIn(expected, added) - self.assertNotIn("128 open pull requests (54 ready, 74 draft)", preamble) - self.assertNotIn("153 open pull requests (39 ready, 114 draft)", added) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py deleted file mode 100644 index 1c24fe674..000000000 --- a/tests/test_product_completion_gap_contract.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Regression contract for the dated commercial-completion gap baseline.""" - -from __future__ import annotations - -import pathlib -import unittest - -ROOT = pathlib.Path(__file__).resolve().parents[1] -BASELINE = ROOT / "docs/product-technical-gap-baseline.md" - - -class ProductCompletionGapContractTests(unittest.TestCase): - """Keep the exact repository snapshot and completion tracks reviewable.""" - - def test_baseline_records_current_inventory_and_completion_issues(self) -> None: - """The dated baseline must not retain superseded queue counts or omit buyer tracks.""" - text = BASELINE.read_text(encoding="utf-8") - - for phrase in ( - "126 open pull requests", - "54 non-draft", - "72 draft", - "2026-08-24 158-PR snapshot", - "#198", - "#199", - "#200", - "#201", - "#202", - "#203", - "durable WARC/PROV replay", - "stable BAP/MCP runtime API", - "signed cross-platform Chromium distribution", - "enterprise control and experience plane", - "commercial acceptance gate", - ): - with self.subTest(phrase=phrase): - self.assertIn(phrase, text) - - for stale_phrase in ( - "100 open pull requests", - "22 non-draft", - "78 draft", - "148 open pull requests", - "79 draft PRs", - "40 non-draft", - "110 draft", - "150 open pull requests", - "prior 150-PR snapshot", - "128 open pull requests", - "74 draft", - ): - with self.subTest(stale_phrase=stale_phrase): - self.assertNotIn(stale_phrase, text) - - def test_active_github_approval_rule_is_not_documented_as_bypassable(self) -> None: - """An active counted-approval rule must stop merge without an eligible approver.""" - text = BASELINE.read_text(encoding="utf-8") - - self.assertIn("eligible non-author", text) - self.assertIn("reviewer-provisioning gap", text) - self.assertNotIn("owner-directed administrative merge", text) - - def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> None: - """The evidence procedure must paginate the queue and inspect each exact PR head.""" - text = BASELINE.read_text(encoding="utf-8") - evidence = text.split("## Evidence commands", 1)[1].split("\n## ", 1)[0] - shell = evidence.split("```bash", 1)[1].split("```", 1)[0] - - for phrase in ( - "--paginate --slurp 'repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100'", - "set -euo pipefail", - 'EVIDENCE_DIR="$(mktemp -d /tmp/originweave-evidence.XXXXXX)"', - '"$EVIDENCE_DIR/open-pr-pages.json"', - "jq '[.[][]]' \"$EVIDENCE_DIR/open-pr-pages.json\"", - '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR"', - '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100"', - '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/statuses?per_page=100"', - '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100"', - '"repos/ContextualWisdomLab/OriginWeave/actions/runs?head_sha=$HEAD_SHA&per_page=100"', - "check_runs: [$checks[][].check_runs[]?],", - "legacy_statuses: [$statuses[][][]?]", - "workflow_runs: [$workflow_runs[][].workflow_runs[]?],", - "reviewThreads(first: 100, after: $endCursor)", - "rules/branches/main?per_page=100", - '"$EVIDENCE_DIR/main-branch-rule-pages.json"', - '"$EVIDENCE_DIR/collaborator-pages.json"', - '"$EVIDENCE_DIR/collaborators.json"', - '"$EVIDENCE_DIR/pr-${PR}-merge-verdict.json.tmp"', - '.state == "APPROVED"', - ".submitted_at != null", - ".commit_id == $head", - "group_by(.reviewer)", - "required_approving_review_count", - "require_last_push_approval", - "last_push_approval_authority", - '"github_rule_evaluation_required"', - "if $pull_request_parameters.require_last_push_approval == true then false", - "$pr[0].user.login", - '.type == "workflows"', - ".parameters.workflows", - "required_status_checks", - '"$EVIDENCE_DIR/pr-${PR}-merge-verdict.json"', - "for ATTEMPT in 1 2 3; do", - "RECHECKED_HEAD_SHA=", - "RECHECKED_BASE_SHA=", - 'if [[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" && "$RECHECKED_BASE_SHA" == "$BASE_SHA" ]]; then', - ): - with self.subTest(phrase=phrase): - self.assertIn(phrase, shell) - - self.assertNotIn("while :; do", shell) - self.assertNotIn("/tmp/originweave-open-pr", shell) - self.assertNotIn("check_runs: [$checks[]?.check_runs[]?],", shell) - self.assertNotIn("legacy_statuses: [$statuses[][]?]", shell) - self.assertNotIn("workflow_runs: [$workflow_runs[]?.workflow_runs[]?],", shell) - self.assertNotIn("$reviews[][]?\n | select(.state", shell) - self.assertNotIn("head-commit.json", shell) - self.assertNotIn("$head_commit[0].committer.login", shell) - self.assertNotIn("$head_commit[0].author.login", shell) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_product_documentation_contract.py b/tests/test_product_documentation_contract.py index f192aaa4d..1313189ea 100644 --- a/tests/test_product_documentation_contract.py +++ b/tests/test_product_documentation_contract.py @@ -11,14 +11,6 @@ class ProductDocumentationContractTests(unittest.TestCase): """Keep product requirements, technical design, diagrams, and traceability discoverable.""" - @staticmethod - def _subsection(text: str, heading: str) -> str: - """Return one fourth-level documentation subsection.""" - start = text.index(heading) + len(heading) - remainder = text[start:] - end = remainder.find("\n#### ") - return remainder if end == -1 else remainder[:end] - def test_authoritative_product_documentation_graph_exists(self) -> None: """Major product decisions must not require reconstructing chat or PR history.""" required_paths = { @@ -33,50 +25,10 @@ def test_authoritative_product_documentation_graph_exists(self) -> None: "docs/OPERABILITY.md", "docs/API_CONTRACT.md", "docs/RELEASE_AND_ROLLBACK.md", - "docs/product-technical-gap-baseline.md", } missing = sorted(path for path in required_paths if not (ROOT / path).is_file()) self.assertEqual(missing, []) - def test_product_technical_gap_baseline_records_live_delivery_state(self) -> None: - """Buyers and maintainers must see implementation gaps and current delivery blockers together.""" - baseline = ROOT / "docs/product-technical-gap-baseline.md" - self.assertTrue(baseline.is_file()) - text = baseline.read_text(encoding="utf-8") - for phrase in ( - "Observed snapshot: 2026-08-26", - "Protected-main truth", - "Open pull requests", - "Open issues", - "#195", - "#149", - "reviewer-provisioning gap", - "Phase 1", - ): - with self.subTest(phrase=phrase): - self.assertIn(phrase, text) - - protected_main = text.split("### Open pull requests", 1)[0] - open_pull_requests = text.split("### Open pull requests", 1)[1].split( - "### Review and merge authority", 1 - )[0] - self.assertIn("Phase 1 is **in progress**, not shipped.", protected_main) - self.assertIn( - "none of them is protected-main behavior until merged", - open_pull_requests, - ) - bidi_status = self._subsection( - open_pull_requests, "#### #195/#198 WebDriver BiDi opening path status" - ) - vpn_status = self._subsection( - open_pull_requests, "#### #149 VPN/profile intent status" - ) - self.assertIn("Phase 1 is **in progress**, not shipped.", bidi_status) - self.assertIn( - "does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof", - vpn_status, - ) - def test_root_architecture_links_the_authoritative_product_graph(self) -> None: """Architecture readers must be able to reach requirements, decisions, diagrams, and data.""" architecture = (ROOT / "ARCHITECTURE.md").read_text(encoding="utf-8") @@ -87,7 +39,6 @@ def test_root_architecture_links_the_authoritative_product_graph(self) -> None: "docs/uml/README.md", "docs/erd/README.md", "docs/traceability/README.md", - "docs/product-technical-gap-baseline.md", ): with self.subTest(link=link): self.assertIn(link, architecture) diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 057a0011b..360e11143 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -20,7 +20,6 @@ def test_workspace_declares_all_independently_reusable_crates(self) -> None: set(data["workspace"]["members"]), { "crates/originweave-core", - "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-destination", "crates/originweave-network", @@ -60,7 +59,6 @@ def test_required_architecture_and_governance_documents_exist(self) -> None: "docs/adr/0005-direct-socket-binding.md", "docs/adr/0006-tls-server-identity.md", "docs/adr/0009-hourly-agent-credential-boundary.md", - "docs/adr/0016-bap-task-lifecycle-authority.md", "docs/superpowers/specs/2026-08-06-resolved-destination-policy-design.md", "docs/superpowers/specs/2026-08-06-direct-socket-binding-design.md", "docs/superpowers/specs/2026-08-06-tls-server-identity-design.md", @@ -187,4 +185,4 @@ def test_database_contract_requires_two_word_snake_case(self) -> None: if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file diff --git a/tests/test_rust_toolchain_contract.py b/tests/test_rust_toolchain_contract.py deleted file mode 100644 index 058add241..000000000 --- a/tests/test_rust_toolchain_contract.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Regression contracts for the reproducible Rust compiler baseline.""" - -from __future__ import annotations - -import tomllib -import unittest -from pathlib import Path - - -REPOSITORY_ROOT = Path(__file__).resolve().parents[1] -RUST_TOOLCHAIN = REPOSITORY_ROOT / "rust-toolchain.toml" -CI_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "ci.yml" -HOURLY_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "hourly-product-development.yml" -REFRESH_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "apply-rust-nightly-refresh.yml" -DEPENDABOT = REPOSITORY_ROOT / ".github" / "dependabot.yml" - - -class RustToolchainContractTests(unittest.TestCase): - """Keep stable builds reproducible and branch coverage intentionally fresh.""" - - def test_stable_toolchain_is_exact_and_automatically_tracked(self) -> None: - """The stable compiler changes only through a reviewable manifest update.""" - - manifest = tomllib.loads(RUST_TOOLCHAIN.read_text(encoding="utf-8")) - self.assertEqual(manifest["toolchain"]["channel"], "1.97.1") - - dependabot = DEPENDABOT.read_text(encoding="utf-8") - self.assertIn('package-ecosystem: "rust-toolchain"', dependabot) - self.assertIn('directory: "/"', dependabot) - self.assertIn('interval: "weekly"', dependabot) - - def test_branch_coverage_uses_one_current_date_pinned_nightly(self) -> None: - """Every branch-coverage command uses the same reviewed nightly snapshot.""" - - workflow = CI_WORKFLOW.read_text(encoding="utf-8") - self.assertEqual(workflow.count("nightly-2026-08-18"), 3) - self.assertNotIn("nightly-2026-08-01", workflow) - - hourly_workflow = HOURLY_WORKFLOW.read_text(encoding="utf-8") - self.assertEqual(hourly_workflow.count("nightly-2026-08-18"), 2) - self.assertNotIn("nightly-2026-08-01", hourly_workflow) - - def test_nightly_refresh_accepts_only_old_or_already_refreshed_source(self) -> None: - """The one-shot materializer remains valid after the source is refreshed.""" - workflow = REFRESH_WORKFLOW.read_text(encoding="utf-8") - self.assertIn("old_count = source.count(old)", workflow) - self.assertIn("new_count = source.count(new)", workflow) - self.assertIn("if old_count == 2 and new_count == 0:", workflow) - self.assertIn("elif old_count == 0 and new_count == 2:", workflow) - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/test_webdriver_bidi_connect_target_governance.py b/tests/test_webdriver_bidi_connect_target_governance.py new file mode 100644 index 000000000..de7949478 --- /dev/null +++ b/tests/test_webdriver_bidi_connect_target_governance.py @@ -0,0 +1,35 @@ +"""Governance regression for explicit WebDriver BiDi socket destinations.""" + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +CHANGELOG = ROOT / "CHANGELOG.md" + + +class WebDriverBiDiConnectTargetGovernanceTests(unittest.TestCase): + """Keep the active no-DNS transport boundary visible in release evidence.""" + + def test_changelog_records_explicit_no_dns_connect_target_boundary(self) -> None: + """The production connect-target slice must have a truthful Unreleased record.""" + changelog = CHANGELOG.read_text(encoding="utf-8") + self.assertIn( + "Explicit no-DNS WebDriver BiDi loopback connection targets", + changelog, + ) + self.assertIn("localhost", changelog) + self.assertIn("no socket I/O", changelog) + self.assertIn("no Agent authority", changelog) + + def test_changelog_records_exact_connected_peer_verification_boundary(self) -> None: + """Verified socket-peer metadata must be visible without overstating transport trust.""" + changelog = CHANGELOG.read_text(encoding="utf-8") + self.assertIn("Exact WebDriver BiDi socket-peer verification", changelog) + self.assertIn("IP address and port", changelog) + self.assertIn("does not authenticate an OS process", changelog) + self.assertIn("does not negotiate TLS", changelog) + + +if __name__ == "__main__": + unittest.main() From 6ba8669283ded62443506b1171f3b43d58419ca8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:39:38 +0900 Subject: [PATCH 553/570] fix(core): document loopback websocket exception Signed-off-by: Seongho Bae --- .../originweave-core/src/webdriver_bidi_websocket_endpoint.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs index 83d2e9b04..4ab4fe077 100644 --- a/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs +++ b/crates/originweave-core/src/webdriver_bidi_websocket_endpoint.rs @@ -51,6 +51,8 @@ impl WebDriverBiDiWebSocketEndpoint { return Err(WebDriverBiDiWebSocketEndpointAdmissionError::QueryOrFragmentForbidden); } + // Plaintext BiDi is admitted only after exact loopback authority validation below. + // nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket let (secure, remainder) = if let Some(remainder) = value.strip_prefix("ws://") { (false, remainder) } else if let Some(remainder) = value.strip_prefix("wss://") { From b852245aa8da9ddc14ac5dbff5ebf1bcc665e65c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:07:40 +0900 Subject: [PATCH 554/570] test(network): hold opening peer through timeout cleanup Keep the accepted loopback socket alive until the client verifies that the bounded write timeout was cleared. This removes the macOS close race without weakening production cleanup failures. Commit-Message-Assisted-by: Claude (via Claude Code) Signed-off-by: Seongho Bae --- CHANGELOG.md | 1 + .../src/webdriver_bidi_websocket_handshake.rs | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a3ba2526..8fbeed619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Made the agent-development contract work-conserving: completing one bounded slice, RCA, review request, check, merge, or documentation change is an intermediate state; maintenance must return to the live queue, treat waits as item-local, and perform a mandatory exit sweep before terminating while executable OriginWeave work remains. - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. - Kept the real loopback WebDriver BiDi opening-write regression test fail-fast with test-only diagnostics, while explicitly covering successful and panicked server-thread handoffs so strict all-target Clippy and exact coverage remain clean. +- Kept the loopback peer alive until opening-write timeout cleanup completes, removing a macOS close race that could report `EINVAL` after a successful request write without weakening production cleanup failures. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. ### Security diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs index 948d80089..822f4154c 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -452,7 +452,7 @@ fn write_request_with_clock( #[allow(clippy::expect_used)] mod opening_write_tests { use super::*; - use std::{collections::VecDeque, net::TcpListener, thread}; + use std::{collections::VecDeque, net::TcpListener, sync::mpsc, thread}; #[derive(Debug)] enum WriteAction { @@ -539,7 +539,11 @@ mod opening_write_tests { let address = listener .local_addr() .expect("test listener address must be available"); - let server = thread::spawn(move || listener.accept().map(|_| ())); + let (release_server, await_release) = mpsc::sync_channel(0); + let server = thread::spawn(move || { + let (_stream, _peer) = listener.accept().expect("test server must accept client"); + await_release.recv().map_err(io::Error::other) + }); let mut stream = TcpStream::connect(address).expect("test client must connect"); let start = Instant::now(); let mut now = || start; @@ -555,6 +559,9 @@ mod opening_write_tests { .expect("the socket timeout must be inspectable"), None ); + release_server + .send(()) + .expect("the loopback server must remain available through timeout cleanup"); assert!(!join_loopback_server(server)); } From 48eb2d23009c1c804520dd5efcd0d4d072aacef1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:35:53 +0900 Subject: [PATCH 555/570] test(network): hold revoked peer through write classification Keep the accepted loopback socket alive until local shutdown and fail-closed write classification complete, removing a macOS ENOTCONN race from coverage. Commit-Message-Assisted-by: Claude (via Claude Code) Signed-off-by: Seongho Bae --- CHANGELOG.md | 1 + .../tests/webdriver_bidi_websocket_handshake.rs | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fbeed619..c41fa3705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Moved autonomous-agent Cargo targets and Python bytecode caches outside the proposed source tree and prefetched locked Cargo dependencies for offline verification. - Kept the real loopback WebDriver BiDi opening-write regression test fail-fast with test-only diagnostics, while explicitly covering successful and panicked server-thread handoffs so strict all-target Clippy and exact coverage remain clean. - Kept the loopback peer alive until opening-write timeout cleanup completes, removing a macOS close race that could report `EINVAL` after a successful request write without weakening production cleanup failures. +- Kept the revoked-stream fixture peer alive until local shutdown and fail-closed write classification complete, removing a macOS `ENOTCONN` race from the coverage path. - Updated research doctoring to pin Chromium canonicalizer evidence to an immutable revision, add RFC 9293, RFC 5280, RFC 8446, RFC 9525, rustls 0.23.42, and Rust `TcpStream` evidence, distinguish the April 2026 Fugu beta from the June 2026 release, and treat vendor benchmark claims as first-party evidence rather than independent validation. ### Security diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs index be447fd90..a08549581 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -1,5 +1,6 @@ use std::{ net::{Shutdown, TcpListener}, + sync::mpsc, thread, time::Duration, }; @@ -150,7 +151,13 @@ fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { let Ok(local_addr) = local_addr else { return; }; - let server = thread::spawn(move || listener.accept().map(|_| ())); + let (release_server, await_release) = mpsc::sync_channel(0); + let server = thread::spawn(move || { + let accepted = listener.accept()?; + await_release.recv().map_err(std::io::Error::other)?; + drop(accepted); + Ok::<(), std::io::Error>(()) + }); let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); let connection = connect(&endpoint); @@ -180,6 +187,7 @@ fn opening_write_fails_closed_after_verified_stream_is_locally_revoked() { _ => false, }; assert!(failed_closed_without_writing); + assert!(release_server.send(()).is_ok()); let server_result = server.join(); assert!(server_result.is_ok(), "{server_result:?}"); From 29dd314501299a3ad8276e5d73189591ff6327a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 12:08:46 +0900 Subject: [PATCH 556/570] repair(stack): restore protected product assets --- Cargo.lock | 31 ++ Cargo.toml | 1 + crates/originweave-bap/Cargo.toml | 12 + crates/originweave-bap/src/lib.rs | 329 ++++++++++++ .../originweave-bap/tests/task_lifecycle.rs | 253 +++++++++ .../tests/task_lifecycle_recovery.rs | 142 ++++++ crates/originweave-core/Cargo.toml | 1 + crates/originweave-core/src/contracts.rs | 37 +- crates/originweave-core/src/lib.rs | 4 + crates/originweave-core/src/mcp.rs | 478 ++++++++++++++++++ .../src/release_acceptance.rs | 368 ++++++++++++++ .../tests/extension_authority.rs | 119 ++++- .../tests/mcp_authority_route.rs | 362 +++++++++++++ .../tests/mcp_tools_list_cache.rs | 221 ++++++++ .../tests/origin_port_syntax.rs | 18 + .../tests/release_acceptance.rs | 397 +++++++++++++++ .../release_acceptance_canonical_text.rs | 116 +++++ ...elease_acceptance_meaningful_limitation.rs | 46 ++ .../release_acceptance_resource_bounds.rs | 98 ++++ .../tests/release_acceptance_unicode17.rs | 121 +++++ crates/originweave-destination/src/lib.rs | 3 +- crates/originweave-destination/src/proxy.rs | 3 + .../originweave-destination/src/resolution.rs | 236 +++++++++ .../tests/proxy_port_syntax.rs | 29 ++ .../tests/resolution_freshness.rs | 235 +++++++++ .../resolution_post_expiry_revalidation.rs | 78 +++ crates/originweave-policy/src/lib.rs | 20 + .../tests/extension_mutation_isolation.rs | 343 +++++++++++++ .../tests/extension_policy_isolation.rs | 215 ++++++++ .../tests/extension_secret_isolation.rs | 96 ++++ .../tests/mcp_route_binding.rs | 96 ++++ crates/originweave-resource/src/lib.rs | 15 + .../tests/error_contract.rs | 21 + crates/originweave-tls/src/lib.rs | 2 + crates/originweave-tls/src/revocation.rs | 174 +++++++ crates/originweave-tls/src/trust.rs | 1 + .../originweave-tls/tests/policy_contract.rs | 2 +- .../tests/revocation_freshness.rs | 119 +++++ docs/adr/0016-bap-task-lifecycle-authority.md | 123 +++++ docs/doctoring/rust-toolchain-freshness.md | 44 ++ docs/product-technical-gap-baseline.md | 346 +++++++++++++ docs/traceability/mcp-authority-route.md | 58 +++ tests/fixtures/agent_task_basic/index.html | 42 ++ tests/test_agent_task_fixture_contract.py | 137 +++++ tests/test_doctoring_reference_contract.py | 28 + ...test_gap_snapshot_inventory_consistency.py | 58 +++ tests/test_product_completion_gap_contract.py | 123 +++++ tests/test_rust_toolchain_contract.py | 43 ++ 48 files changed, 5835 insertions(+), 9 deletions(-) create mode 100644 crates/originweave-bap/Cargo.toml create mode 100644 crates/originweave-bap/src/lib.rs create mode 100644 crates/originweave-bap/tests/task_lifecycle.rs create mode 100644 crates/originweave-bap/tests/task_lifecycle_recovery.rs create mode 100644 crates/originweave-core/src/mcp.rs create mode 100644 crates/originweave-core/src/release_acceptance.rs create mode 100644 crates/originweave-core/tests/mcp_authority_route.rs create mode 100644 crates/originweave-core/tests/mcp_tools_list_cache.rs create mode 100644 crates/originweave-core/tests/origin_port_syntax.rs create mode 100644 crates/originweave-core/tests/release_acceptance.rs create mode 100644 crates/originweave-core/tests/release_acceptance_canonical_text.rs create mode 100644 crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs create mode 100644 crates/originweave-core/tests/release_acceptance_resource_bounds.rs create mode 100644 crates/originweave-core/tests/release_acceptance_unicode17.rs create mode 100644 crates/originweave-destination/tests/proxy_port_syntax.rs create mode 100644 crates/originweave-destination/tests/resolution_freshness.rs create mode 100644 crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs create mode 100644 crates/originweave-policy/tests/extension_mutation_isolation.rs create mode 100644 crates/originweave-policy/tests/extension_policy_isolation.rs create mode 100644 crates/originweave-policy/tests/extension_secret_isolation.rs create mode 100644 crates/originweave-policy/tests/mcp_route_binding.rs create mode 100644 crates/originweave-resource/tests/error_contract.rs create mode 100644 crates/originweave-tls/src/revocation.rs create mode 100644 crates/originweave-tls/tests/revocation_freshness.rs create mode 100644 docs/adr/0016-bap-task-lifecycle-authority.md create mode 100644 docs/doctoring/rust-toolchain-freshness.md create mode 100644 docs/product-technical-gap-baseline.md create mode 100644 docs/traceability/mcp-authority-route.md create mode 100644 tests/fixtures/agent_task_basic/index.html create mode 100644 tests/test_agent_task_fixture_contract.py create mode 100644 tests/test_doctoring_reference_contract.py create mode 100644 tests/test_gap_snapshot_inventory_consistency.py create mode 100644 tests/test_product_completion_gap_contract.py create mode 100644 tests/test_rust_toolchain_contract.py diff --git a/Cargo.lock b/Cargo.lock index e2ada3c4e..848cb7320 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,9 +263,16 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "originweave-bap" +version = "0.1.0" + [[package]] name = "originweave-core" version = "0.1.0" +dependencies = [ + "unicode-normalization", +] [[package]] name = "originweave-destination" @@ -554,6 +561,21 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "typenum" version = "1.20.1" @@ -566,6 +588,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index fc723f3a4..0d5ab469c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/originweave-core", + "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-resource", "crates/originweave-evidence", diff --git a/crates/originweave-bap/Cargo.toml b/crates/originweave-bap/Cargo.toml new file mode 100644 index 000000000..39e8e38f7 --- /dev/null +++ b/crates/originweave-bap/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "originweave-bap" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[lints] +workspace = true diff --git a/crates/originweave-bap/src/lib.rs b/crates/originweave-bap/src/lib.rs new file mode 100644 index 000000000..404a88c10 --- /dev/null +++ b/crates/originweave-bap/src/lib.rs @@ -0,0 +1,329 @@ +//! Stable internal Browser Agent Protocol lifecycle contracts. +//! +//! This crate intentionally owns no transport, browser, network, model, secret, +//! approval, or persistence authority. External protocol adapters may project +//! these states, but protocol metadata cannot mint or change OriginWeave task +//! authority. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +/// Durable logical state of one governed BAP task. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskState { + /// The task record exists but has not entered admission control. + Created, + /// Admission control accepted the task but execution has not started. + Admitted, + /// The task is actively executing governed work. + Running, + /// Execution is suspended until an approval decision is available. + WaitingForApproval, + /// Execution is suspended until required external input is available. + WaitingForExternalInput, + /// Execution is suspended at a compatible recoverable checkpoint. + Checkpointed, + /// Execution is suspended until an explicit reconciliation decision is recorded. + /// + /// The lifecycle state does not itself persist or authenticate reconciliation + /// evidence. A durable owner must preserve the complete evidence that caused + /// the task to enter this state before resolution is considered. + ReconciliationRequired, + /// The declared post-condition completed successfully. + Succeeded, + /// The task reached a terminal execution failure. + Failed, + /// Cancellation completed and the task cannot resume. + Cancelled, + /// The task exceeded its allowed lifetime and cannot resume. + Expired, + /// The task was terminally removed from automatic execution after governed handling. + /// + /// Durable dead-letter evidence remains the responsibility of the persistence + /// boundary; this in-memory marker must not be treated as the evidence itself. + DeadLettered, +} + +impl BapTaskState { + /// Return whether this state is final and must never transition again. + #[must_use] + pub const fn is_terminal(self) -> bool { + matches!( + self, + Self::Succeeded | Self::Failed | Self::Cancelled | Self::Expired | Self::DeadLettered + ) + } +} + +/// One requested task-lifecycle event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskEvent { + /// Admit a newly created task. + Admit, + /// Start an admitted task. + Start, + /// Suspend a running task until approval is available. + WaitForApproval, + /// Suspend a running task until external input is available. + WaitForExternalInput, + /// Suspend a running task at a recoverable checkpoint. + Checkpoint, + /// Resume a normal suspended task into governed execution. + Resume, + /// Suspend a running task because its external outcome requires reconciliation. + RequireReconciliation, + /// Explicitly resolve a reconciliation hold and return the task to governed execution. + ResolveReconciliation, + /// Terminally remove a running or reconciliation-held task from automatic execution. + DeadLetter, + /// Record successful completion after the declared post-condition is verified. + Succeed, + /// Record terminal task failure. + Fail, + /// Record terminal cancellation. + Cancel, + /// Record terminal expiry. + Expire, +} + +/// A fail-closed lifecycle transition failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskTransitionError { + /// The requested event is not valid from the current non-terminal state. + InvalidTransition { + /// Current state that rejected the event. + from: BapTaskState, + /// Event that was rejected. + event: BapTaskEvent, + }, + /// The lifecycle sequence reached its maximum representable value. + SequenceExhausted, + /// A terminal task cannot be reopened or mutated by lifecycle events. + TerminalState { + /// Final state that rejected all further events. + state: BapTaskState, + }, +} + +impl std::fmt::Display for BapTaskTransitionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidTransition { from, event } => { + write!( + formatter, + "BAP task event {event:?} is invalid from state {from:?}" + ) + } + Self::SequenceExhausted => { + write!(formatter, "BAP task transition sequence is exhausted") + } + Self::TerminalState { state } => { + write!(formatter, "BAP task state {state:?} is terminal") + } + } + } +} + +impl std::error::Error for BapTaskTransitionError {} + +/// A fail-closed lifecycle recovery failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BapTaskRestoreError { + /// The supplied state and transition sequence cannot arise from this state machine. + InvalidSnapshot { + /// Logical state supplied by the durable recovery boundary. + state: BapTaskState, + /// Last accepted transition sequence supplied by the durable recovery boundary. + transition_sequence: u64, + }, +} + +impl std::fmt::Display for BapTaskRestoreError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidSnapshot { + state, + transition_sequence, + } => write!( + formatter, + "BAP task snapshot state {state:?} with transition sequence {transition_sequence} is unreachable" + ), + } + } +} + +impl std::error::Error for BapTaskRestoreError {} + +/// Immutable receipt for one accepted in-memory lifecycle transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BapTaskTransition { + previous_state: BapTaskState, + current_state: BapTaskState, + sequence: u64, +} + +impl BapTaskTransition { + /// Return the state before the accepted transition. + #[must_use] + pub const fn previous_state(self) -> BapTaskState { + self.previous_state + } + + /// Return the state after the accepted transition. + #[must_use] + pub const fn current_state(self) -> BapTaskState { + self.current_state + } + + /// Return the monotonic transition sequence for this lifecycle instance. + #[must_use] + pub const fn sequence(self) -> u64 { + self.sequence + } +} + +/// Deterministic fail-closed BAP task-lifecycle kernel. +/// +/// This value is intentionally an in-memory state-transition primitive. A +/// durable repository must persist accepted transitions and impose its own +/// bounded sequence/retention contract before commercial task recovery can be +/// claimed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BapTaskLifecycle { + state: BapTaskState, + transition_sequence: u64, +} + +impl Default for BapTaskLifecycle { + fn default() -> Self { + Self::new() + } +} + +impl BapTaskLifecycle { + /// Create one lifecycle in the `created` state with no accepted transitions. + #[must_use] + pub const fn new() -> Self { + Self { + state: BapTaskState::Created, + transition_sequence: 0, + } + } + + /// Restore a lifecycle state and its last accepted transition sequence. + /// + /// Recovery accepts only state/sequence pairs that are reachable through + /// this exact state machine. This prevents corrupt or stale durable metadata + /// from manufacturing an impossible execution state. + pub const fn restore( + state: BapTaskState, + transition_sequence: u64, + ) -> Result { + if !reachable_snapshot(state, transition_sequence) { + return Err(BapTaskRestoreError::InvalidSnapshot { + state, + transition_sequence, + }); + } + Ok(Self { + state, + transition_sequence, + }) + } + + /// Return the current logical task state. + #[must_use] + pub const fn state(self) -> BapTaskState { + self.state + } + + /// Return the number of accepted lifecycle transitions. + #[must_use] + pub const fn transition_sequence(self) -> u64 { + self.transition_sequence + } + + /// Apply one reviewed lifecycle event without granting execution authority. + /// + /// Rejected events leave both state and sequence unchanged. Terminal states + /// reject every later event before evaluating any normal transition rule. + /// Reconciliation cannot use the generic `Resume` event: it requires the + /// explicit `ResolveReconciliation` event so ambiguous external outcomes + /// cannot silently re-enter execution. + pub fn apply( + &mut self, + event: BapTaskEvent, + ) -> Result { + if self.state.is_terminal() { + return Err(BapTaskTransitionError::TerminalState { state: self.state }); + } + + let next_state = match (self.state, event) { + (BapTaskState::Created, BapTaskEvent::Admit) => BapTaskState::Admitted, + (BapTaskState::Admitted, BapTaskEvent::Start) => BapTaskState::Running, + (BapTaskState::Running, BapTaskEvent::WaitForApproval) => { + BapTaskState::WaitingForApproval + } + (BapTaskState::Running, BapTaskEvent::WaitForExternalInput) => { + BapTaskState::WaitingForExternalInput + } + (BapTaskState::Running, BapTaskEvent::Checkpoint) => BapTaskState::Checkpointed, + ( + BapTaskState::WaitingForApproval + | BapTaskState::WaitingForExternalInput + | BapTaskState::Checkpointed, + BapTaskEvent::Resume, + ) => BapTaskState::Running, + (BapTaskState::Running, BapTaskEvent::RequireReconciliation) => { + BapTaskState::ReconciliationRequired + } + (BapTaskState::ReconciliationRequired, BapTaskEvent::ResolveReconciliation) => { + BapTaskState::Running + } + ( + BapTaskState::Running | BapTaskState::ReconciliationRequired, + BapTaskEvent::DeadLetter, + ) => BapTaskState::DeadLettered, + (BapTaskState::Running, BapTaskEvent::Succeed) => BapTaskState::Succeeded, + (_, BapTaskEvent::Fail) => BapTaskState::Failed, + (_, BapTaskEvent::Cancel) => BapTaskState::Cancelled, + (_, BapTaskEvent::Expire) => BapTaskState::Expired, + (from, event) => { + return Err(BapTaskTransitionError::InvalidTransition { from, event }); + } + }; + + let Some(sequence) = self.transition_sequence.checked_add(1) else { + return Err(BapTaskTransitionError::SequenceExhausted); + }; + let previous_state = self.state; + self.state = next_state; + self.transition_sequence = sequence; + Ok(BapTaskTransition { + previous_state, + current_state: next_state, + sequence, + }) + } +} + +const fn reachable_snapshot(state: BapTaskState, transition_sequence: u64) -> bool { + match state { + BapTaskState::Created => transition_sequence == 0, + BapTaskState::Admitted => transition_sequence == 1, + BapTaskState::Running => transition_sequence >= 2 && transition_sequence.is_multiple_of(2), + BapTaskState::WaitingForApproval + | BapTaskState::WaitingForExternalInput + | BapTaskState::Checkpointed + | BapTaskState::ReconciliationRequired => { + transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) + } + BapTaskState::Succeeded => { + transition_sequence >= 3 && !transition_sequence.is_multiple_of(2) + } + BapTaskState::Failed | BapTaskState::Cancelled | BapTaskState::Expired => { + transition_sequence >= 1 + } + BapTaskState::DeadLettered => transition_sequence >= 3, + } +} diff --git a/crates/originweave-bap/tests/task_lifecycle.rs b/crates/originweave-bap/tests/task_lifecycle.rs new file mode 100644 index 000000000..01013682a --- /dev/null +++ b/crates/originweave-bap/tests/task_lifecycle.rs @@ -0,0 +1,253 @@ +#![allow(clippy::expect_used)] + +use originweave_bap::{BapTaskEvent, BapTaskLifecycle, BapTaskState, BapTaskTransitionError}; + +#[test] +fn default_starts_a_new_created_lifecycle() { + assert_eq!(BapTaskLifecycle::default(), BapTaskLifecycle::new()); +} + +#[test] +fn bap_task_lifecycle_follows_the_reviewed_resumable_path() { + let mut task = BapTaskLifecycle::new(); + assert_eq!(task.state(), BapTaskState::Created); + assert!(!task.state().is_terminal()); + assert_eq!(task.transition_sequence(), 0); + + let admitted = task.apply(BapTaskEvent::Admit).expect("admit"); + assert_eq!(admitted.previous_state(), BapTaskState::Created); + assert_eq!(admitted.current_state(), BapTaskState::Admitted); + assert_eq!(admitted.sequence(), 1); + + task.apply(BapTaskEvent::Start).expect("start"); + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait for approval"); + assert_eq!(task.state(), BapTaskState::WaitingForApproval); + + task.apply(BapTaskEvent::Resume).expect("resume approval"); + task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); + assert_eq!(task.state(), BapTaskState::Checkpointed); + + task.apply(BapTaskEvent::Resume).expect("resume checkpoint"); + let succeeded = task.apply(BapTaskEvent::Succeed).expect("succeed"); + assert_eq!(succeeded.current_state(), BapTaskState::Succeeded); + assert!(task.state().is_terminal()); + assert_eq!(task.transition_sequence(), 7); +} + +#[test] +fn waiting_for_external_input_can_resume_but_cannot_succeed_directly() { + let mut task = running_task(); + task.apply(BapTaskEvent::WaitForExternalInput) + .expect("wait for input"); + + let error = task + .apply(BapTaskEvent::Succeed) + .expect_err("waiting task must not skip resume and post-condition work"); + assert_eq!( + error, + BapTaskTransitionError::InvalidTransition { + from: BapTaskState::WaitingForExternalInput, + event: BapTaskEvent::Succeed, + } + ); + assert_eq!(task.state(), BapTaskState::WaitingForExternalInput); + assert_eq!(task.transition_sequence(), 3); + + task.apply(BapTaskEvent::Resume).expect("resume input"); + assert_eq!(task.state(), BapTaskState::Running); +} + +#[test] +fn invalid_transition_is_fail_closed_and_does_not_advance_history() { + let mut task = BapTaskLifecycle::new(); + + let error = task + .apply(BapTaskEvent::Start) + .expect_err("created task must be admitted first"); + assert_eq!( + error, + BapTaskTransitionError::InvalidTransition { + from: BapTaskState::Created, + event: BapTaskEvent::Start, + } + ); + assert_eq!(task.state(), BapTaskState::Created); + assert_eq!(task.transition_sequence(), 0); +} + +#[test] +fn terminal_task_never_reopens_or_advances_history() { + for terminal_event in [ + BapTaskEvent::Succeed, + BapTaskEvent::Fail, + BapTaskEvent::Cancel, + BapTaskEvent::Expire, + ] { + let mut task = if terminal_event == BapTaskEvent::Succeed { + running_task() + } else { + BapTaskLifecycle::new() + }; + task.apply(terminal_event).expect("enter terminal state"); + let terminal_state = task.state(); + let terminal_sequence = task.transition_sequence(); + + for later_event in [ + BapTaskEvent::Admit, + BapTaskEvent::Start, + BapTaskEvent::Resume, + BapTaskEvent::Cancel, + ] { + assert_eq!( + task.apply(later_event), + Err(BapTaskTransitionError::TerminalState { + state: terminal_state, + }) + ); + assert_eq!(task.state(), terminal_state); + assert_eq!(task.transition_sequence(), terminal_sequence); + } + } +} + +#[test] +fn cancellation_and_expiry_cover_pre_dispatch_and_suspended_states() { + for state in [ + BapTaskState::Created, + BapTaskState::Admitted, + BapTaskState::Running, + BapTaskState::WaitingForApproval, + BapTaskState::WaitingForExternalInput, + BapTaskState::Checkpointed, + BapTaskState::ReconciliationRequired, + ] { + for terminal_event in [BapTaskEvent::Cancel, BapTaskEvent::Expire] { + let mut task = task_in_state(state); + assert_eq!(task.state(), state); + task.apply(terminal_event).expect("terminal interruption"); + assert!(task.state().is_terminal()); + } + } +} + +#[test] +fn reconciliation_requires_explicit_resolution_and_dead_letter_is_terminal() { + let mut task = running_task(); + let required = task + .apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation"); + assert_eq!(required.previous_state(), BapTaskState::Running); + assert_eq!( + required.current_state(), + BapTaskState::ReconciliationRequired + ); + assert!(!task.state().is_terminal()); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::ReconciliationRequired, + event: BapTaskEvent::Resume, + }) + ); + assert_eq!( + task.apply(BapTaskEvent::Succeed), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::ReconciliationRequired, + event: BapTaskEvent::Succeed, + }) + ); + assert_eq!(task.transition_sequence(), 3); + + task.apply(BapTaskEvent::ResolveReconciliation) + .expect("resolve reconciliation"); + assert_eq!(task.state(), BapTaskState::Running); + + task.apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation again"); + let dead_lettered = task + .apply(BapTaskEvent::DeadLetter) + .expect("dead-letter unresolved task"); + assert_eq!(dead_lettered.current_state(), BapTaskState::DeadLettered); + assert!(task.state().is_terminal()); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::TerminalState { + state: BapTaskState::DeadLettered, + }) + ); +} + +#[test] +fn running_task_may_dead_letter_but_pre_dispatch_task_may_not() { + let mut running = running_task(); + let transition = running + .apply(BapTaskEvent::DeadLetter) + .expect("dead-letter running task"); + assert_eq!(transition.previous_state(), BapTaskState::Running); + assert_eq!(transition.current_state(), BapTaskState::DeadLettered); + assert_eq!(transition.sequence(), 3); + assert!(running.state().is_terminal()); + + let mut created = BapTaskLifecycle::new(); + assert_eq!( + created.apply(BapTaskEvent::DeadLetter), + Err(BapTaskTransitionError::InvalidTransition { + from: BapTaskState::Created, + event: BapTaskEvent::DeadLetter, + }) + ); + assert_eq!(created.state(), BapTaskState::Created); + assert_eq!(created.transition_sequence(), 0); +} + +fn running_task() -> BapTaskLifecycle { + let mut task = BapTaskLifecycle::new(); + task.apply(BapTaskEvent::Admit).expect("admit"); + task.apply(BapTaskEvent::Start).expect("start"); + task +} + +fn task_in_state(target: BapTaskState) -> BapTaskLifecycle { + let mut task = BapTaskLifecycle::new(); + if target == BapTaskState::Created { + return task; + } + + task.apply(BapTaskEvent::Admit).expect("admit"); + if target == BapTaskState::Admitted { + return task; + } + + task.apply(BapTaskEvent::Start).expect("start"); + match target { + BapTaskState::Running => {} + BapTaskState::WaitingForApproval => { + task.apply(BapTaskEvent::WaitForApproval) + .expect("wait approval"); + } + BapTaskState::WaitingForExternalInput => { + task.apply(BapTaskEvent::WaitForExternalInput) + .expect("wait external"); + } + BapTaskState::Checkpointed => { + task.apply(BapTaskEvent::Checkpoint).expect("checkpoint"); + } + BapTaskState::ReconciliationRequired => { + task.apply(BapTaskEvent::RequireReconciliation) + .expect("require reconciliation"); + } + BapTaskState::Created + | BapTaskState::Admitted + | BapTaskState::Succeeded + | BapTaskState::Failed + | BapTaskState::Cancelled + | BapTaskState::Expired + | BapTaskState::DeadLettered => { + unreachable!("task_in_state only constructs non-terminal lifecycle states") + } + } + task +} diff --git a/crates/originweave-bap/tests/task_lifecycle_recovery.rs b/crates/originweave-bap/tests/task_lifecycle_recovery.rs new file mode 100644 index 000000000..67deae949 --- /dev/null +++ b/crates/originweave-bap/tests/task_lifecycle_recovery.rs @@ -0,0 +1,142 @@ +#![allow(clippy::expect_used)] + +use std::error::Error as _; + +use originweave_bap::{ + BapTaskEvent, BapTaskLifecycle, BapTaskRestoreError, BapTaskState, BapTaskTransitionError, +}; + +#[test] +fn restored_lifecycle_preserves_state_and_monotonic_sequence() { + let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, 41) + .expect("valid checkpoint snapshot"); + + assert_eq!(task.state(), BapTaskState::Checkpointed); + assert_eq!(task.transition_sequence(), 41); + + let resumed = task + .apply(BapTaskEvent::Resume) + .expect("resume restored task"); + assert_eq!(resumed.previous_state(), BapTaskState::Checkpointed); + assert_eq!(resumed.current_state(), BapTaskState::Running); + assert_eq!(resumed.sequence(), 42); +} + +#[test] +fn impossible_restored_snapshots_fail_closed() { + for (state, sequence) in [ + (BapTaskState::Created, 1), + (BapTaskState::Admitted, 0), + (BapTaskState::Admitted, 2), + (BapTaskState::Running, 1), + (BapTaskState::Running, 3), + (BapTaskState::WaitingForApproval, 2), + (BapTaskState::WaitingForApproval, 4), + (BapTaskState::WaitingForExternalInput, 2), + (BapTaskState::WaitingForExternalInput, 4), + (BapTaskState::Checkpointed, 2), + (BapTaskState::Checkpointed, 4), + (BapTaskState::ReconciliationRequired, 2), + (BapTaskState::ReconciliationRequired, 4), + (BapTaskState::Succeeded, 2), + (BapTaskState::Succeeded, 4), + (BapTaskState::Failed, 0), + (BapTaskState::Cancelled, 0), + (BapTaskState::Expired, 0), + (BapTaskState::DeadLettered, 2), + ] { + assert_eq!( + BapTaskLifecycle::restore(state, sequence), + Err(BapTaskRestoreError::InvalidSnapshot { + state, + transition_sequence: sequence, + }), + "state={state:?}, sequence={sequence}", + ); + } +} + +#[test] +fn valid_restored_snapshot_classes_remain_accepted() { + for (state, sequence) in [ + (BapTaskState::Created, 0), + (BapTaskState::Admitted, 1), + (BapTaskState::Running, 2), + (BapTaskState::Running, 4), + (BapTaskState::WaitingForApproval, 3), + (BapTaskState::WaitingForExternalInput, 5), + (BapTaskState::Checkpointed, 7), + (BapTaskState::ReconciliationRequired, 3), + (BapTaskState::Succeeded, 3), + (BapTaskState::Failed, 1), + (BapTaskState::Cancelled, 2), + (BapTaskState::Expired, 4), + (BapTaskState::DeadLettered, 3), + (BapTaskState::DeadLettered, 4), + ] { + let task = BapTaskLifecycle::restore(state, sequence).expect("reachable snapshot"); + assert_eq!(task.state(), state); + assert_eq!(task.transition_sequence(), sequence); + } +} + +#[test] +fn exhausted_sequence_fails_closed_without_mutating_state() { + let mut task = BapTaskLifecycle::restore(BapTaskState::Checkpointed, u64::MAX) + .expect("valid exhausted checkpoint snapshot"); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::SequenceExhausted), + ); + assert_eq!(task.state(), BapTaskState::Checkpointed); + assert_eq!(task.transition_sequence(), u64::MAX); +} + +#[test] +fn restored_terminal_lifecycle_remains_terminal() { + let mut task = + BapTaskLifecycle::restore(BapTaskState::Succeeded, 9).expect("valid terminal snapshot"); + + assert_eq!( + task.apply(BapTaskEvent::Resume), + Err(BapTaskTransitionError::TerminalState { + state: BapTaskState::Succeeded, + }), + ); + assert_eq!(task.transition_sequence(), 9); +} + +#[test] +fn lifecycle_failures_use_the_standard_rust_error_contract() { + let mut created = BapTaskLifecycle::new(); + let invalid_transition = created + .apply(BapTaskEvent::Start) + .expect_err("created task must reject start"); + assert_eq!( + invalid_transition.to_string(), + "BAP task event Start is invalid from state Created" + ); + assert!(invalid_transition.source().is_none()); + + let exhausted = BapTaskTransitionError::SequenceExhausted; + assert_eq!( + exhausted.to_string(), + "BAP task transition sequence is exhausted" + ); + assert!(exhausted.source().is_none()); + + let terminal = BapTaskTransitionError::TerminalState { + state: BapTaskState::Cancelled, + }; + assert_eq!(terminal.to_string(), "BAP task state Cancelled is terminal"); + assert!(terminal.source().is_none()); + + let restore = BapTaskLifecycle::restore(BapTaskState::Created, 1) + .expect_err("unreachable snapshot must fail"); + assert_eq!( + restore.to_string(), + "BAP task snapshot state Created with transition sequence 1 is unreachable" + ); + assert!(restore.source().is_none()); +} diff --git a/crates/originweave-core/Cargo.toml b/crates/originweave-core/Cargo.toml index 35c83b19b..f1273b69a 100644 --- a/crates/originweave-core/Cargo.toml +++ b/crates/originweave-core/Cargo.toml @@ -11,6 +11,7 @@ homepage.workspace = true publish = false [dependencies] +unicode-normalization = "=0.1.25" [lints] workspace = true diff --git a/crates/originweave-core/src/contracts.rs b/crates/originweave-core/src/contracts.rs index 88dd2e586..e33a7e7e5 100644 --- a/crates/originweave-core/src/contracts.rs +++ b/crates/originweave-core/src/contracts.rs @@ -165,6 +165,9 @@ fn parse_bracketed_ipv6(authority: &str) -> Result<(String, Option, bool), } fn parse_port(port_text: &str) -> Result { + if port_text.is_empty() || !port_text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(OriginError::InvalidPort); + } let port = port_text .parse::() .map_err(|_error| OriginError::InvalidPort)?; @@ -967,16 +970,20 @@ pub struct ExtensionAgentGrant { extension_id: ExtensionId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, + origin: Origin, + expires_at_epoch_seconds: u64, capabilities: BTreeSet, } impl ExtensionAgentGrant { - /// Build an exact extension-to-Agent grant for one browser session and context. + /// Build an exact extension-to-Agent grant for one session, context, origin, and exclusive expiry. #[must_use] pub fn new( extension_id: ExtensionId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, + origin: Origin, + expires_at_epoch_seconds: u64, capabilities: I, ) -> Self where @@ -986,6 +993,8 @@ impl ExtensionAgentGrant { extension_id, browser_session, browsing_context, + origin, + expires_at_epoch_seconds, capabilities: capabilities.into_iter().collect(), } } @@ -997,22 +1006,31 @@ pub struct ExtensionAccessRequest { extension_id: ExtensionId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, + origin: Origin, + now_epoch_seconds: u64, capability: ExtensionAgentCapability, } impl ExtensionAccessRequest { /// Build one exact extension capability request without granting authority. + /// + /// `now_epoch_seconds` must be trusted evaluation time supplied by the host, + /// not a page, extension, or model clock. #[must_use] pub const fn new( extension_id: ExtensionId, browser_session: BrowserSessionId, browsing_context: BrowsingContextId, + origin: Origin, + now_epoch_seconds: u64, capability: ExtensionAgentCapability, ) -> Self { Self { extension_id, browser_session, browsing_context, + origin, + now_epoch_seconds, capability, } } @@ -1021,7 +1039,7 @@ impl ExtensionAccessRequest { /// Result of evaluating an extension request against one explicit Agent grant. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExtensionAccessDecision { - /// The exact extension, session, context, and capability are explicitly granted. + /// The exact extension, session, context, origin, unexpired grant, and capability are explicitly granted. Allow, /// No explicit extension-to-Agent grant was supplied. DenyMissingGrant, @@ -1031,6 +1049,10 @@ pub enum ExtensionAccessDecision { DenyBrowserSessionMismatch, /// The request belongs to a different independently navigable browser context. DenyBrowsingContextMismatch, + /// The request belongs to a different canonical origin than the grant. + DenyOriginMismatch, + /// Trusted evaluation time is at or after the grant's exclusive expiry. + DenyExpired, /// The extension grant does not contain the requested OriginWeave capability. DenyCapabilityNotGranted, } @@ -1039,8 +1061,9 @@ pub enum ExtensionAccessDecision { /// /// A Chrome extension permission, installation state, or page capability is never /// consulted here. A future Chromium adapter must construct a host-originated -/// [`ExtensionAgentGrant`] explicitly and re-evaluate the exact session/context -/// request at the boundary where Agent authority would otherwise cross. +/// [`ExtensionAgentGrant`] explicitly and re-evaluate the exact session, context, +/// canonical origin, and exclusive expiry at the boundary where Agent authority +/// would otherwise cross. #[must_use] pub fn evaluate_extension_access( request: &ExtensionAccessRequest, @@ -1058,6 +1081,12 @@ pub fn evaluate_extension_access( if request.browsing_context != grant.browsing_context { return ExtensionAccessDecision::DenyBrowsingContextMismatch; } + if request.origin != grant.origin { + return ExtensionAccessDecision::DenyOriginMismatch; + } + if request.now_epoch_seconds >= grant.expires_at_epoch_seconds { + return ExtensionAccessDecision::DenyExpired; + } if !grant.capabilities.contains(&request.capability) { return ExtensionAccessDecision::DenyCapabilityNotGranted; } diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index b9d69c8b0..9ea902c2a 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -31,6 +31,10 @@ mod browser_registry; #[cfg(test)] mod browser_registry_coverage; mod contracts; +/// Stateless MCP routing validation that maps only explicit tools to typed actions. +pub mod mcp; +/// Deterministic fail-closed release benchmark acceptance aggregation. +pub mod release_acceptance; mod webdriver_bidi_command; mod webdriver_bidi_error_code; mod webdriver_bidi_response_document; diff --git a/crates/originweave-core/src/mcp.rs b/crates/originweave-core/src/mcp.rs new file mode 100644 index 000000000..c7200e327 --- /dev/null +++ b/crates/originweave-core/src/mcp.rs @@ -0,0 +1,478 @@ +//! Fail-closed MCP routing integrity for the external adapter boundary. +//! +//! This module validates only the stateless MCP protocol/method/tool routing +//! envelope and derives an existing [`ActionKind`]. It is deliberately not an +//! authorization decision: callers must independently enforce OriginWeave +//! capability, risk, approval, origin, secret-broker, and evidence policies. +//! No MCP arguments, outputs, credentials, or arbitrary model-visible values +//! are retained by this boundary. + +use std::fmt; + +use crate::{ActionKind, Capability, RiskClass}; + +/// MCP protocol generation accepted by this stateless adapter boundary. +pub const MCP_PROTOCOL_VERSION: &str = "2026-07-28"; + +/// The only MCP method that can enter the typed action-routing boundary. +pub const MCP_TOOLS_CALL_METHOD: &str = "tools/call"; + +/// The MCP discovery method accepted by the typed tools-list boundary. +pub const MCP_TOOLS_LIST_METHOD: &str = "tools/list"; + +/// Maximum accepted MCP method-name length in bytes. +pub const MAX_MCP_METHOD_NAME_BYTES: usize = 64; + +/// Maximum accepted MCP tool-name length in bytes. +pub const MAX_MCP_TOOL_NAME_BYTES: usize = 128; + +/// One deterministic MCP tool descriptor derived from OriginWeave's reviewed action registry. +/// +/// The descriptor is discovery metadata only. It does not grant capabilities, origin access, +/// approval, secret access, or any other authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct McpToolCatalogEntry { + tool_name: &'static str, + action_kind: ActionKind, +} + +impl McpToolCatalogEntry { + /// Return the canonical MCP tool name exposed by this registry entry. + #[must_use] + pub const fn tool_name(&self) -> &'static str { + self.tool_name + } + + /// Return the typed OriginWeave action represented by this registry entry. + #[must_use] + pub const fn action_kind(&self) -> ActionKind { + self.action_kind + } + + /// Return the capability required by the represented action. + #[must_use] + pub const fn required_capability(&self) -> Capability { + self.action_kind.required_capability() + } + + /// Return the risk class assigned to the represented action. + #[must_use] + pub const fn risk_class(&self) -> RiskClass { + self.action_kind.risk_class() + } +} + +/// The complete explicit MCP tool-to-action registry accepted by this boundary. +/// +/// Order is deterministic so adapters can derive stable discovery output from this single +/// reviewed registry rather than maintaining a second mapping that could drift from routing. +const MCP_TOOL_CATALOG: &[McpToolCatalogEntry] = &[ + McpToolCatalogEntry { + tool_name: "originweave.observe", + action_kind: ActionKind::Observe, + }, + McpToolCatalogEntry { + tool_name: "originweave.extract", + action_kind: ActionKind::Extract, + }, + McpToolCatalogEntry { + tool_name: "originweave.navigate", + action_kind: ActionKind::Navigate, + }, + McpToolCatalogEntry { + tool_name: "originweave.download", + action_kind: ActionKind::Download, + }, + McpToolCatalogEntry { + tool_name: "originweave.draft", + action_kind: ActionKind::Draft, + }, + McpToolCatalogEntry { + tool_name: "originweave.submit", + action_kind: ActionKind::Submit, + }, + McpToolCatalogEntry { + tool_name: "originweave.upload", + action_kind: ActionKind::Upload, + }, + McpToolCatalogEntry { + tool_name: "originweave.fill_secret", + action_kind: ActionKind::FillSecret, + }, + McpToolCatalogEntry { + tool_name: "originweave.purchase", + action_kind: ActionKind::Purchase, + }, + McpToolCatalogEntry { + tool_name: "originweave.delete", + action_kind: ActionKind::Delete, + }, + McpToolCatalogEntry { + tool_name: "originweave.manage_permission", + action_kind: ActionKind::ManagePermission, + }, +]; + +/// Return the deterministic reviewed MCP tool catalog. +/// +/// Adapters may use this slice to derive discovery responses. Serialization, pagination, cache +/// policy, transport I/O, and authorization remain outside this stateless registry boundary. +#[must_use] +pub const fn supported_mcp_tools() -> &'static [McpToolCatalogEntry] { + MCP_TOOL_CATALOG +} + +/// Protocol disposition carried by a typed MCP result. +/// +/// OriginWeave currently constructs only terminal results at this boundary. A transport adapter +/// must serialize [`Self::Complete`] as MCP's `"complete"` result type and must not omit or +/// reinterpret the required protocol field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpResultType { + /// The request completed and this value contains the final result. + Complete, +} + +/// Cache-sharing scope for an MCP cacheable list result. +/// +/// OriginWeave currently exposes only the conservative private scope. A transport adapter must +/// serialize this as MCP's `"private"` cache scope and must not widen it without a separately +/// reviewed policy that proves the returned catalog is safe to share across callers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpCacheScope { + /// The result may be cached only for the current caller's private context. + Private, +} + +/// One typed MCP `tools/list` page derived from the reviewed tool catalog. +/// +/// This value is discovery metadata only. It does not grant any tool capability or action +/// authority. The initial contract is deliberately one complete private page with zero freshness +/// so adapters cannot omit MCP's required result disposition or accidentally share or reuse +/// discovery metadata beyond the current request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct McpToolsListPage { + result_type: McpResultType, + tools: &'static [McpToolCatalogEntry], + ttl_ms: u64, + cache_scope: McpCacheScope, + next_cursor: Option<&'static str>, +} + +impl McpToolsListPage { + /// Return the mandatory MCP result disposition for this list page. + #[must_use] + pub const fn result_type(&self) -> McpResultType { + self.result_type + } + + /// Return the deterministic reviewed tool entries in this page. + #[must_use] + pub const fn tools(&self) -> &'static [McpToolCatalogEntry] { + self.tools + } + + /// Return the MCP freshness lifetime in milliseconds. + /// + /// The current conservative contract is zero, so clients must treat the result as + /// immediately stale rather than reusing it for a later request. + #[must_use] + pub const fn ttl_ms(&self) -> u64 { + self.ttl_ms + } + + /// Return the MCP cache-sharing scope for this page. + #[must_use] + pub const fn cache_scope(&self) -> McpCacheScope { + self.cache_scope + } + + /// Return the opaque continuation cursor when another page exists. + /// + /// The current fixed catalog is emitted as one complete page, so this is always `None`. + #[must_use] + pub const fn next_cursor(&self) -> Option<&'static str> { + self.next_cursor + } +} + +/// Build the conservative typed MCP `tools/list` result for the reviewed catalog. +/// +/// This function does not perform transport serialization, authorization, or pagination. It +/// binds the catalog to the mandatory complete result disposition plus explicit zero-TTL/private +/// cache hints so adapters cannot invent broader protocol or cache semantics independently from +/// this reviewed boundary. +#[must_use] +pub const fn mcp_tools_list_page() -> McpToolsListPage { + McpToolsListPage { + result_type: McpResultType::Complete, + tools: MCP_TOOL_CATALOG, + ttl_ms: 0, + cache_scope: McpCacheScope::Private, + next_cursor: None, + } +} + +/// A deterministic failure while validating one MCP `tools/list` request envelope. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpToolsListBoundaryError { + /// The transport request omitted the required MCP protocol-version header. + MissingProtocolVersionHeader, + /// The structured request metadata omitted the required MCP protocol version. + MissingProtocolVersionMetadata, + /// The transport protocol version disagrees with the structured request metadata. + ProtocolVersionHeaderBodyMismatch, + /// The request names an MCP protocol generation this boundary does not support. + UnsupportedProtocolVersion, + /// The structured request metadata omitted the required client-capabilities object. + MissingClientCapabilities, + /// The request method violates the bounded ASCII MCP routing syntax. + InvalidMethod, + /// MCP routing method metadata disagrees with the method in the request body. + MethodHeaderBodyMismatch, + /// The request method is not the supported `tools/list` operation. + UnsupportedMethod, + /// The request supplied a cursor that this fixed single-page catalog never issued. + UnsupportedCursor, +} + +impl fmt::Display for McpToolsListBoundaryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingProtocolVersionHeader => { + formatter.write_str("MCP protocol version header is required") + } + Self::MissingProtocolVersionMetadata => { + formatter.write_str("MCP request metadata protocol version is required") + } + Self::ProtocolVersionHeaderBodyMismatch => { + formatter.write_str("MCP protocol version header does not match request metadata") + } + Self::UnsupportedProtocolVersion => { + formatter.write_str("unsupported MCP protocol version") + } + Self::MissingClientCapabilities => { + formatter.write_str("MCP request metadata client capabilities are required") + } + Self::InvalidMethod => { + formatter.write_str("MCP method violates the bounded ASCII routing syntax") + } + Self::MethodHeaderBodyMismatch => { + formatter.write_str("MCP method header does not match the request body") + } + Self::UnsupportedMethod => { + formatter.write_str("only MCP tools/list requests can enter the discovery boundary") + } + Self::UnsupportedCursor => { + formatter.write_str("MCP tools/list cursor was not issued by this fixed catalog") + } + } + } +} + +impl std::error::Error for McpToolsListBoundaryError {} + +/// An MCP `tools/list` request whose protocol, required metadata, and routing envelope were +/// validated. +/// +/// This boundary is deliberately narrower than a general transport or pagination implementation. +/// A trusted structured parser must prove whether the required per-request client-capabilities +/// object was present; this type never accepts its contents as authority. The current reviewed +/// catalog returns one complete page and emits no continuation cursor, so no non-null cursor can +/// be a value previously issued by OriginWeave. A transport adapter must not silently ignore or +/// reinterpret a supplied cursor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ValidatedMcpToolsListRequest { + method: &'static str, +} + +impl ValidatedMcpToolsListRequest { + /// Validate the stateless request envelope for the current fixed `tools/list` catalog. + /// + /// Both the required transport protocol-version header and structured request `_meta` + /// protocol version must be present, individually bounded to the exact supported-version + /// length before cross-field comparison, equal, and exactly [`MCP_PROTOCOL_VERSION`]. A + /// trusted structured parser must also attest that the required `_meta` client-capabilities + /// object was present; its contents grant no OriginWeave authority. Each untrusted method + /// value is shape-validated before comparison. The routing/body method must then agree exactly. + /// Any supplied cursor fails closed because [`mcp_tools_list_page`] emits no continuation + /// cursor; accepting one would silently invent pagination state that OriginWeave never issued. + pub fn new( + protocol_version_header: Option<&str>, + protocol_version_metadata: Option<&str>, + client_capabilities_present: bool, + routing_method: &str, + body_method: &str, + cursor: Option<&str>, + ) -> Result { + let protocol_version_header = protocol_version_header + .ok_or(McpToolsListBoundaryError::MissingProtocolVersionHeader)?; + let protocol_version_metadata = protocol_version_metadata + .ok_or(McpToolsListBoundaryError::MissingProtocolVersionMetadata)?; + + if protocol_version_header.len() > MCP_PROTOCOL_VERSION.len() + || protocol_version_metadata.len() > MCP_PROTOCOL_VERSION.len() + { + return Err(McpToolsListBoundaryError::UnsupportedProtocolVersion); + } + if protocol_version_header != protocol_version_metadata { + return Err(McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch); + } + if protocol_version_metadata != MCP_PROTOCOL_VERSION { + return Err(McpToolsListBoundaryError::UnsupportedProtocolVersion); + } + if !client_capabilities_present { + return Err(McpToolsListBoundaryError::MissingClientCapabilities); + } + if !valid_method(routing_method) || !valid_method(body_method) { + return Err(McpToolsListBoundaryError::InvalidMethod); + } + if routing_method != body_method { + return Err(McpToolsListBoundaryError::MethodHeaderBodyMismatch); + } + if routing_method != MCP_TOOLS_LIST_METHOD { + return Err(McpToolsListBoundaryError::UnsupportedMethod); + } + if cursor.is_some() { + return Err(McpToolsListBoundaryError::UnsupportedCursor); + } + + Ok(Self { + method: MCP_TOOLS_LIST_METHOD, + }) + } + + /// Return the canonical MCP method validated by this request. + #[must_use] + pub const fn method(&self) -> &'static str { + self.method + } +} + +/// A deterministic failure while validating untrusted MCP routing metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpToolBoundaryError { + /// The request names an MCP protocol generation this boundary does not support. + UnsupportedProtocolVersion, + /// MCP routing metadata disagrees with the method or tool name in the body. + HeaderBodyMismatch, + /// The request method violates the bounded ASCII MCP routing syntax. + InvalidMethod, + /// The request method is not the supported `tools/call` operation. + UnsupportedMethod, + /// The tool name violates the bounded ASCII MCP routing syntax. + InvalidToolName, + /// The tool name has no explicit mapping to an OriginWeave typed action. + UnknownTool, +} + +impl fmt::Display for McpToolBoundaryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedProtocolVersion => { + formatter.write_str("unsupported MCP protocol version") + } + Self::HeaderBodyMismatch => { + formatter.write_str("MCP routing headers do not match the request body") + } + Self::InvalidMethod => { + formatter.write_str("MCP method violates the bounded ASCII routing syntax") + } + Self::UnsupportedMethod => formatter + .write_str("only MCP tools/call requests can enter the typed action boundary"), + Self::InvalidToolName => { + formatter.write_str("MCP tool name violates the bounded ASCII routing syntax") + } + Self::UnknownTool => { + formatter.write_str("MCP tool is not mapped to an OriginWeave typed action") + } + } + } +} + +impl std::error::Error for McpToolBoundaryError {} + +/// An MCP tool call whose routing envelope has been validated and mapped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ValidatedMcpToolCall { + tool_name: &'static str, + action_kind: ActionKind, +} + +impl ValidatedMcpToolCall { + /// Validate one stateless MCP tool-call routing envelope. + /// + /// Routing integrity is intentionally narrower than authorization. A + /// successful value proves only that the untrusted protocol version, + /// routing metadata, body method, and body tool name agree with one + /// explicitly supported mapping. Each untrusted method and tool name is + /// shape-validated before cross-field comparison so malformed or oversized + /// metadata cannot bypass the bounded routing syntax through mismatch handling. + pub fn new( + protocol_version: &str, + routing_method: &str, + routing_tool_name: &str, + body_method: &str, + body_tool_name: &str, + ) -> Result { + if protocol_version != MCP_PROTOCOL_VERSION { + return Err(McpToolBoundaryError::UnsupportedProtocolVersion); + } + if !valid_method(routing_method) || !valid_method(body_method) { + return Err(McpToolBoundaryError::InvalidMethod); + } + if !valid_tool_name(routing_tool_name) || !valid_tool_name(body_tool_name) { + return Err(McpToolBoundaryError::InvalidToolName); + } + if routing_method != body_method || routing_tool_name != body_tool_name { + return Err(McpToolBoundaryError::HeaderBodyMismatch); + } + if routing_method != MCP_TOOLS_CALL_METHOD { + return Err(McpToolBoundaryError::UnsupportedMethod); + } + + let (tool_name, action_kind) = map_tool(routing_tool_name)?; + Ok(Self { + tool_name, + action_kind, + }) + } + + /// Return the canonical static tool name selected by the explicit mapping. + #[must_use] + pub const fn tool_name(&self) -> &'static str { + self.tool_name + } + + /// Return the existing OriginWeave typed action selected by this tool. + #[must_use] + pub const fn action_kind(&self) -> ActionKind { + self.action_kind + } +} + +fn valid_method(method: &str) -> bool { + if method.is_empty() || method.len() > MAX_MCP_METHOD_NAME_BYTES { + return false; + } + method + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/')) +} + +fn valid_tool_name(tool_name: &str) -> bool { + if tool_name.is_empty() || tool_name.len() > MAX_MCP_TOOL_NAME_BYTES { + return false; + } + tool_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +fn map_tool(tool_name: &str) -> Result<(&'static str, ActionKind), McpToolBoundaryError> { + MCP_TOOL_CATALOG + .iter() + .find(|entry| entry.tool_name == tool_name) + .map(|entry| (entry.tool_name, entry.action_kind)) + .ok_or(McpToolBoundaryError::UnknownTool) +} diff --git a/crates/originweave-core/src/release_acceptance.rs b/crates/originweave-core/src/release_acceptance.rs new file mode 100644 index 000000000..a3655de52 --- /dev/null +++ b/crates/originweave-core/src/release_acceptance.rs @@ -0,0 +1,368 @@ +//! Deterministic fail-closed release acceptance for commercial benchmark evidence. +//! +//! This module aggregates only explicit mandatory-suite outcomes and bounded, +//! buyer-visible limitations. It does not execute benchmarks, infer missing +//! evidence, authenticate artifacts, or grant release authority. + +use std::fmt; + +use unicode_normalization::is_nfc; + +/// Maximum UTF-8 byte length retained for either buyer-visible limitation field. +pub const MAX_RELEASE_LIMITATION_TEXT_BYTES: usize = 1024; + +/// Maximum number of buyer-visible limitations retained in one release report. +pub const MAX_DECLARED_RELEASE_LIMITATIONS: usize = 64; + +/// One mandatory benchmark suite in the release acceptance contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BenchmarkSuite { + /// Controlled local fixtures with deterministic post-condition oracles. + ControlledDeterministic, + /// Stable web compatibility tasks for the declared support profile. + WebCompatibility, + /// Hostile security cases that measure unauthorized authority or disclosure. + SecurityAdversarial, + /// Crash, timeout, retry, reconciliation, cleanup, and restore behavior. + ReliabilityRecovery, + /// Enterprise isolation, identity, policy, audit, and operator controls. + EnterpriseOperability, +} + +impl BenchmarkSuite { + /// Every mandatory benchmark suite in canonical release-report order. + pub const ALL: [Self; 5] = [ + Self::ControlledDeterministic, + Self::WebCompatibility, + Self::SecurityAdversarial, + Self::ReliabilityRecovery, + Self::EnterpriseOperability, + ]; + + /// Return the stable snake-case suite identifier used by benchmark evidence. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ControlledDeterministic => "controlled_deterministic_suite", + Self::WebCompatibility => "web_compatibility_suite", + Self::SecurityAdversarial => "security_adversarial_suite", + Self::ReliabilityRecovery => "reliability_recovery_suite", + Self::EnterpriseOperability => "enterprise_operability_suite", + } + } + + const fn index(self) -> usize { + match self { + Self::ControlledDeterministic => 0, + Self::WebCompatibility => 1, + Self::SecurityAdversarial => 2, + Self::ReliabilityRecovery => 3, + Self::EnterpriseOperability => 4, + } + } +} + +/// Evaluated outcome for one mandatory benchmark suite. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BenchmarkSuiteOutcome { + /// Every threshold required for the declared profile passed. + Passed, + /// At least one mandatory threshold is known to have failed. + Failed, + /// Evidence is insufficient to establish either pass or threshold failure. + Inconclusive, +} + +/// One explicit narrowed release claim and its buyer-visible consequence. +/// +/// An accepted-with-limitations decision cannot be produced from an opaque +/// boolean. Every limitation must name the unsupported claim and state the +/// consequence that a buyer must account for in the declared support profile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeclaredLimitation { + unsupported_claim: String, + buyer_consequence: String, +} + +impl DeclaredLimitation { + /// Construct one explicit buyer-visible release limitation. + /// + /// Empty/whitespace-only or punctuation-only values, surrounding whitespace, + /// non-NFC Unicode, fields exceeding the fixed UTF-8 byte budget, and ambiguous + /// presentation characters fail closed because they cannot safely represent one + /// canonical, resource-bounded buyer-visible release limitation. Accepted text + /// is retained byte-for-byte; this constructor never normalizes caller input + /// implicitly. + pub fn new( + unsupported_claim: impl Into, + buyer_consequence: impl Into, + ) -> Result { + Self::from_owned_text(unsupported_claim.into(), buyer_consequence.into()) + } + + fn from_owned_text( + unsupported_claim: String, + buyer_consequence: String, + ) -> Result { + if unsupported_claim.trim().is_empty() { + return Err(ReleaseDecisionError::EmptyLimitationClaim); + } + if unsupported_claim.trim() != unsupported_claim { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } + if unsupported_claim.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { + return Err(ReleaseDecisionError::LimitationClaimTooLong); + } + if !is_nfc(&unsupported_claim) { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } + if unsupported_claim + .chars() + .any(disallowed_release_limitation_character) + || !unsupported_claim.chars().any(char::is_alphanumeric) + { + return Err(ReleaseDecisionError::InvalidLimitationClaim); + } + if buyer_consequence.trim().is_empty() { + return Err(ReleaseDecisionError::EmptyLimitationConsequence); + } + if buyer_consequence.trim() != buyer_consequence { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } + if buyer_consequence.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES { + return Err(ReleaseDecisionError::LimitationConsequenceTooLong); + } + if !is_nfc(&buyer_consequence) { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } + if buyer_consequence + .chars() + .any(disallowed_release_limitation_character) + || !buyer_consequence.chars().any(char::is_alphanumeric) + { + return Err(ReleaseDecisionError::InvalidLimitationConsequence); + } + Ok(Self { + unsupported_claim, + buyer_consequence, + }) + } + + /// Return the exact unsupported or narrowed release claim. + #[must_use] + pub fn unsupported_claim(&self) -> &str { + &self.unsupported_claim + } + + /// Return the exact consequence exposed to buyers and operators. + #[must_use] + pub fn buyer_consequence(&self) -> &str { + &self.buyer_consequence + } +} + +fn disallowed_release_limitation_character(character: char) -> bool { + let code_point = character as u32; + character.is_control() + || matches!( + code_point, + 0x00ad + | 0x034f + | 0x061c + | 0x115f..=0x1160 + | 0x17b4..=0x17b5 + | 0x180b..=0x180f + | 0x200b..=0x200f + | 0x2028..=0x202e + | 0x2060..=0x206f + | 0x3164 + | 0xfe00..=0xfe0f + | 0xfeff + | 0xffa0 + | 0xfff0..=0xfff8 + | 0x1bca0..=0x1bca3 + | 0x1d173..=0x1d17a + | 0xe0000..=0xe0fff + ) +} + +/// Deterministic release decision produced from mandatory suite evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseDecision { + /// Every mandatory suite passed for the full declared support profile. + Accepted, + /// Every mandatory suite passed after buyer-visible limitations were declared. + AcceptedWithDeclaredLimitations, + /// At least one mandatory suite is known to have failed its threshold. + Rejected, + /// No known threshold failure exists, but mandatory evidence is incomplete. + Inconclusive, +} + +/// Fail-closed input error while constructing a release decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseDecisionError { + /// A declared limitation did not identify the unsupported release claim. + EmptyLimitationClaim, + /// A declared limitation claim exceeded the fixed UTF-8 byte budget. + LimitationClaimTooLong, + /// A declared limitation claim was not canonical NFC text or was presentation-unsafe. + InvalidLimitationClaim, + /// A declared limitation did not state the buyer-visible consequence. + EmptyLimitationConsequence, + /// A declared limitation consequence exceeded the fixed UTF-8 byte budget. + LimitationConsequenceTooLong, + /// A limitation consequence was not canonical NFC text or was presentation-unsafe. + InvalidLimitationConsequence, + /// One release report supplied more buyer-visible limitations than the fixed resource budget. + TooManyDeclaredLimitations, + /// More than one limitation used the same unsupported claim identity. + DuplicateLimitationClaim, + /// The same suite appeared more than once instead of one authoritative result. + DuplicateSuite(BenchmarkSuite), +} + +impl fmt::Display for ReleaseDecisionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyLimitationClaim => { + formatter.write_str("declared release limitation must name an unsupported claim") + } + Self::LimitationClaimTooLong => { + formatter.write_str("declared release limitation claim exceeds the byte budget") + } + Self::InvalidLimitationClaim => formatter.write_str( + "declared release limitation claim is not canonical or contains an unsafe presentation character", + ), + Self::EmptyLimitationConsequence => formatter + .write_str("declared release limitation must state a buyer-visible consequence"), + Self::LimitationConsequenceTooLong => formatter + .write_str("declared release limitation consequence exceeds the byte budget"), + Self::InvalidLimitationConsequence => formatter.write_str( + "declared release limitation consequence is not canonical or contains an unsafe presentation character", + ), + Self::TooManyDeclaredLimitations => formatter + .write_str("benchmark release decision contains too many declared limitations"), + Self::DuplicateLimitationClaim => formatter + .write_str("benchmark release decision contains duplicate limitation claim"), + Self::DuplicateSuite(suite) => write!( + formatter, + "benchmark release evidence contains duplicate suite: {}", + suite.as_str() + ), + } + } +} + +impl std::error::Error for ReleaseDecisionError {} + +/// Release decision together with exact mandatory-suite evidence gaps and failures. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseDecisionReport { + decision: ReleaseDecision, + failed_suites: Vec, + inconclusive_suites: Vec, + missing_suites: Vec, + declared_limitations: Vec, +} + +impl ReleaseDecisionReport { + /// Return the deterministic release decision. + #[must_use] + pub const fn decision(&self) -> ReleaseDecision { + self.decision + } + + /// Return suites with a known mandatory-threshold failure. + #[must_use] + pub fn failed_suites(&self) -> &[BenchmarkSuite] { + &self.failed_suites + } + + /// Return suites whose supplied evidence was explicitly inconclusive. + #[must_use] + pub fn inconclusive_suites(&self) -> &[BenchmarkSuite] { + &self.inconclusive_suites + } + + /// Return mandatory suites for which no outcome was supplied. + #[must_use] + pub fn missing_suites(&self) -> &[BenchmarkSuite] { + &self.missing_suites + } + + /// Return the exact buyer-visible limitations retained with this decision. + #[must_use] + pub fn declared_limitations(&self) -> &[DeclaredLimitation] { + &self.declared_limitations + } +} + +/// Produce one deterministic release decision from mandatory suite outcomes. +/// +/// Duplicate suite evidence, duplicate buyer-visible limitation claim identities, +/// and excessive declared-limitation cardinality fail closed rather than selecting +/// or retaining ambiguous or attacker-controlled release metadata. A known +/// mandatory-threshold failure is always rejected, even when other suites are +/// missing or inconclusive; all such evidence gaps remain in the returned report. +/// Without a known failure, missing or inconclusive evidence is never promoted to +/// acceptance. Accepted-with-limitations requires at least one validated +/// [`DeclaredLimitation`], so the decision cannot be detached from the exact +/// narrowed claim and buyer-visible consequence. +pub fn decide_release( + results: I, + declared_limitations: &[DeclaredLimitation], +) -> Result +where + I: IntoIterator, +{ + if declared_limitations.len() > MAX_DECLARED_RELEASE_LIMITATIONS { + return Err(ReleaseDecisionError::TooManyDeclaredLimitations); + } + + let mut limitation_claims = std::collections::BTreeSet::new(); + for limitation in declared_limitations { + if !limitation_claims.insert(limitation.unsupported_claim()) { + return Err(ReleaseDecisionError::DuplicateLimitationClaim); + } + } + + let mut outcomes = [None; BenchmarkSuite::ALL.len()]; + for (suite, outcome) in results { + let slot = &mut outcomes[suite.index()]; + if slot.is_some() { + return Err(ReleaseDecisionError::DuplicateSuite(suite)); + } + *slot = Some(outcome); + } + + let mut failed_suites = Vec::new(); + let mut inconclusive_suites = Vec::new(); + let mut missing_suites = Vec::new(); + for suite in BenchmarkSuite::ALL { + match outcomes[suite.index()] { + Some(BenchmarkSuiteOutcome::Passed) => {} + Some(BenchmarkSuiteOutcome::Failed) => failed_suites.push(suite), + Some(BenchmarkSuiteOutcome::Inconclusive) => inconclusive_suites.push(suite), + None => missing_suites.push(suite), + } + } + + let decision = if !failed_suites.is_empty() { + ReleaseDecision::Rejected + } else if !inconclusive_suites.is_empty() || !missing_suites.is_empty() { + ReleaseDecision::Inconclusive + } else if declared_limitations.is_empty() { + ReleaseDecision::Accepted + } else { + ReleaseDecision::AcceptedWithDeclaredLimitations + }; + + Ok(ReleaseDecisionReport { + decision, + failed_suites, + inconclusive_suites, + missing_suites, + declared_limitations: declared_limitations.to_vec(), + }) +} diff --git a/crates/originweave-core/tests/extension_authority.rs b/crates/originweave-core/tests/extension_authority.rs index 82507a244..f34c30e9b 100644 --- a/crates/originweave-core/tests/extension_authority.rs +++ b/crates/originweave-core/tests/extension_authority.rs @@ -2,7 +2,7 @@ use originweave_core::{ BrowserSessionId, BrowsingContextId, ExtensionAccessDecision, ExtensionAccessRequest, - ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, evaluate_extension_access, + ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, Origin, evaluate_extension_access, }; fn extension_id(value: &str) -> ExtensionId { @@ -17,6 +17,13 @@ fn context(value: u64) -> BrowsingContextId { BrowsingContextId::new(value).expect("nonzero browsing context") } +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("canonical origin") +} + +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + #[test] fn extension_id_accepts_only_canonical_chromium_extension_ids() { let canonical = "abcdefghijklmnopabcdefghijklmnop"; @@ -43,10 +50,13 @@ fn extension_id_accepts_only_canonical_chromium_extension_ids() { fn extension_agent_access_requires_an_explicit_exact_grant() { let allowed_extension = extension_id("abcdefghijklmnopabcdefghijklmnop"); let other_extension = extension_id("bcdefghijklmnopabcdefghijklmnopa"); + let granted_origin = origin("https://app.example"); let grant = ExtensionAgentGrant::new( allowed_extension.clone(), session(7), context(11), + granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ObserveCurrentContext], ); @@ -54,6 +64,8 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { allowed_extension.clone(), session(7), context(11), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -68,6 +80,8 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { other_extension, session(7), context(11), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -79,6 +93,8 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { allowed_extension.clone(), session(8), context(11), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( @@ -87,24 +103,55 @@ fn extension_agent_access_requires_an_explicit_exact_grant() { ); let wrong_context = ExtensionAccessRequest::new( - allowed_extension, + allowed_extension.clone(), session(7), context(12), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ObserveCurrentContext, ); assert_eq!( evaluate_extension_access(&wrong_context, Some(&grant)), ExtensionAccessDecision::DenyBrowsingContextMismatch ); + + let wrong_origin = ExtensionAccessRequest::new( + allowed_extension.clone(), + session(7), + context(11), + origin("https://other.example"), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&wrong_origin, Some(&grant)), + ExtensionAccessDecision::DenyOriginMismatch + ); + + let wrong_port = ExtensionAccessRequest::new( + allowed_extension, + session(7), + context(11), + origin("https://app.example:8443"), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&wrong_port, Some(&grant)), + ExtensionAccessDecision::DenyOriginMismatch + ); } #[test] fn chrome_permissions_never_imply_originweave_agent_capabilities() { let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("https://mail.example"); let grant = ExtensionAgentGrant::new( id.clone(), session(3), context(5), + granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ExtensionAgentCapability::ObserveCurrentContext], ); @@ -112,6 +159,8 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { id, session(3), context(5), + granted_origin, + UNEXPIRED_NOW_EPOCH_SECONDS, ExtensionAgentCapability::ProposeTypedAction, ); assert_eq!( @@ -123,10 +172,13 @@ fn chrome_permissions_never_imply_originweave_agent_capabilities() { #[test] fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("http://127.0.0.1:8080"); let grant = ExtensionAgentGrant::new( id.clone(), session(13), context(17), + granted_origin.clone(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, [ ExtensionAgentCapability::ObserveCurrentContext, ExtensionAgentCapability::ProposeTypedAction, @@ -137,10 +189,71 @@ fn explicit_grant_can_authorize_multiple_bounded_agent_capabilities() { ExtensionAgentCapability::ObserveCurrentContext, ExtensionAgentCapability::ProposeTypedAction, ] { - let request = ExtensionAccessRequest::new(id.clone(), session(13), context(17), capability); + let request = ExtensionAccessRequest::new( + id.clone(), + session(13), + context(17), + granted_origin.clone(), + UNEXPIRED_NOW_EPOCH_SECONDS, + capability, + ); assert_eq!( evaluate_extension_access(&request, Some(&grant)), ExtensionAccessDecision::Allow ); } } + +#[test] +fn expired_origin_bound_grant_cannot_be_reused_after_exclusive_deadline() { + let id = extension_id("abcdefghijklmnopabcdefghijklmnop"); + let granted_origin = origin("https://billing.example"); + let expires_at_epoch_seconds = 1_700_000_100; + let grant = ExtensionAgentGrant::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds, + [ExtensionAgentCapability::ObserveCurrentContext], + ); + + let before_deadline = ExtensionAccessRequest::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds - 1, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&before_deadline, Some(&grant)), + ExtensionAccessDecision::Allow + ); + + let at_deadline = ExtensionAccessRequest::new( + id.clone(), + session(19), + context(23), + granted_origin.clone(), + expires_at_epoch_seconds, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&at_deadline, Some(&grant)), + ExtensionAccessDecision::DenyExpired + ); + + let after_deadline = ExtensionAccessRequest::new( + id, + session(19), + context(23), + granted_origin, + expires_at_epoch_seconds + 1, + ExtensionAgentCapability::ObserveCurrentContext, + ); + assert_eq!( + evaluate_extension_access(&after_deadline, Some(&grant)), + ExtensionAccessDecision::DenyExpired + ); +} diff --git a/crates/originweave-core/tests/mcp_authority_route.rs b/crates/originweave-core/tests/mcp_authority_route.rs new file mode 100644 index 000000000..80357ec63 --- /dev/null +++ b/crates/originweave-core/tests/mcp_authority_route.rs @@ -0,0 +1,362 @@ +use std::error::Error; + +use originweave_core::mcp::{ + MAX_MCP_METHOD_NAME_BYTES, MAX_MCP_TOOL_NAME_BYTES, MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, McpToolBoundaryError, ValidatedMcpToolCall, supported_mcp_tools, +}; +use originweave_core::{ActionKind, Capability, RiskClass}; + +fn validate(tool_name: &str) -> Result { + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + tool_name, + MCP_TOOLS_CALL_METHOD, + tool_name, + ) +} + +#[test] +fn supported_mcp_tools_map_to_exact_originweave_actions() -> Result<(), Box> { + let cases = [ + ( + "originweave.observe", + ActionKind::Observe, + Capability::Observe, + RiskClass::R0, + ), + ( + "originweave.extract", + ActionKind::Extract, + Capability::Extract, + RiskClass::R0, + ), + ( + "originweave.navigate", + ActionKind::Navigate, + Capability::Navigate, + RiskClass::R1, + ), + ( + "originweave.download", + ActionKind::Download, + Capability::Download, + RiskClass::R1, + ), + ( + "originweave.draft", + ActionKind::Draft, + Capability::Draft, + RiskClass::R2, + ), + ( + "originweave.submit", + ActionKind::Submit, + Capability::Submit, + RiskClass::R3, + ), + ( + "originweave.upload", + ActionKind::Upload, + Capability::Upload, + RiskClass::R3, + ), + ( + "originweave.fill_secret", + ActionKind::FillSecret, + Capability::FillSecret, + RiskClass::R3, + ), + ( + "originweave.purchase", + ActionKind::Purchase, + Capability::Purchase, + RiskClass::R4, + ), + ( + "originweave.delete", + ActionKind::Delete, + Capability::Delete, + RiskClass::R4, + ), + ( + "originweave.manage_permission", + ActionKind::ManagePermission, + Capability::ManagePermission, + RiskClass::R4, + ), + ]; + + for (tool_name, expected_action, expected_capability, expected_risk) in cases { + let call = validate(tool_name)?; + assert_eq!(call.tool_name(), tool_name); + assert_eq!(call.action_kind(), expected_action); + assert_eq!( + call.action_kind().required_capability(), + expected_capability + ); + assert_eq!(call.action_kind().risk_class(), expected_risk); + } + Ok(()) +} + +#[test] +fn mcp_tool_catalog_is_deterministic_complete_and_action_unambiguous() -> Result<(), Box> +{ + let expected = [ + ("originweave.observe", ActionKind::Observe), + ("originweave.extract", ActionKind::Extract), + ("originweave.navigate", ActionKind::Navigate), + ("originweave.download", ActionKind::Download), + ("originweave.draft", ActionKind::Draft), + ("originweave.submit", ActionKind::Submit), + ("originweave.upload", ActionKind::Upload), + ("originweave.fill_secret", ActionKind::FillSecret), + ("originweave.purchase", ActionKind::Purchase), + ("originweave.delete", ActionKind::Delete), + ( + "originweave.manage_permission", + ActionKind::ManagePermission, + ), + ]; + let catalog = supported_mcp_tools(); + + assert_eq!(catalog.len(), expected.len()); + for (entry, (expected_name, expected_action)) in catalog.iter().zip(expected) { + assert_eq!(entry.tool_name(), expected_name); + assert_eq!(entry.action_kind(), expected_action); + assert_eq!( + entry.required_capability(), + expected_action.required_capability() + ); + assert_eq!(entry.risk_class(), expected_action.risk_class()); + + let call = validate(entry.tool_name())?; + assert_eq!(call.action_kind(), entry.action_kind()); + } + + for (index, entry) in catalog.iter().enumerate() { + for other in &catalog[index + 1..] { + assert_ne!(entry.tool_name(), other.tool_name()); + assert_ne!(entry.action_kind(), other.action_kind()); + } + } + assert!( + catalog + .iter() + .all(|entry| entry.action_kind() != ActionKind::LegalConsent) + ); + Ok(()) +} + +#[test] +fn mcp_route_rejects_protocol_header_body_and_method_drift() { + assert_eq!( + ValidatedMcpToolCall::new( + "2025-11-25", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::UnsupportedProtocolVersion) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + "tools/list", + "originweave.observe", + ), + Err(McpToolBoundaryError::HeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.extract", + ), + Err(McpToolBoundaryError::HeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + "resources/read", + "originweave.observe", + "resources/read", + "originweave.observe", + ), + Err(McpToolBoundaryError::UnsupportedMethod) + ); +} + +#[test] +fn mcp_route_bounds_each_untrusted_method_before_cross_field_comparison() { + let at_limit = "x".repeat(MAX_MCP_METHOD_NAME_BYTES); + let oversized_routing = "r".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); + let oversized_body = "b".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); + + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + "", + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + "", + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + &oversized_routing, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + &oversized_body, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + "tools call", + "originweave.observe", + "tools call", + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidMethod) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + &at_limit, + "originweave.observe", + &at_limit, + "originweave.observe", + ), + Err(McpToolBoundaryError::UnsupportedMethod) + ); +} + +#[test] +fn mcp_route_rejects_unbounded_malformed_and_unmapped_tool_names() { + let at_limit = "x".repeat(MAX_MCP_TOOL_NAME_BYTES); + let oversized = "x".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); + for tool_name in [ + "", + "originweave legal", + "originweave/observe", + "originweave.관찰", + &oversized, + ] { + assert_eq!( + validate(tool_name), + Err(McpToolBoundaryError::InvalidToolName) + ); + } + + assert_eq!(validate(&at_limit), Err(McpToolBoundaryError::UnknownTool)); + assert_eq!( + validate("originweave.legal_consent"), + Err(McpToolBoundaryError::UnknownTool) + ); + assert_eq!( + validate("third_party.arbitrary_javascript"), + Err(McpToolBoundaryError::UnknownTool) + ); +} + +#[test] +fn mcp_route_validates_each_untrusted_tool_name_before_cross_field_comparison() { + let oversized_routing = "r".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); + let oversized_body = "b".repeat(MAX_MCP_TOOL_NAME_BYTES + 1); + + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + &oversized_routing, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidToolName) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + MCP_TOOLS_CALL_METHOD, + &oversized_body, + ), + Err(McpToolBoundaryError::InvalidToolName) + ); + assert_eq!( + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + "originweave/observe", + MCP_TOOLS_CALL_METHOD, + "originweave.observe", + ), + Err(McpToolBoundaryError::InvalidToolName) + ); +} + +#[test] +fn mcp_boundary_errors_are_deterministic_and_do_not_echo_untrusted_values() { + let cases = [ + ( + McpToolBoundaryError::UnsupportedProtocolVersion, + "unsupported MCP protocol version", + ), + ( + McpToolBoundaryError::HeaderBodyMismatch, + "MCP routing headers do not match the request body", + ), + ( + McpToolBoundaryError::UnsupportedMethod, + "only MCP tools/call requests can enter the typed action boundary", + ), + ( + McpToolBoundaryError::InvalidMethod, + "MCP method violates the bounded ASCII routing syntax", + ), + ( + McpToolBoundaryError::InvalidToolName, + "MCP tool name violates the bounded ASCII routing syntax", + ), + ( + McpToolBoundaryError::UnknownTool, + "MCP tool is not mapped to an OriginWeave typed action", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/mcp_tools_list_cache.rs b/crates/originweave-core/tests/mcp_tools_list_cache.rs new file mode 100644 index 000000000..9d3681673 --- /dev/null +++ b/crates/originweave-core/tests/mcp_tools_list_cache.rs @@ -0,0 +1,221 @@ +use std::error::Error; + +use originweave_core::mcp::{ + MAX_MCP_METHOD_NAME_BYTES, MCP_PROTOCOL_VERSION, MCP_TOOLS_LIST_METHOD, McpCacheScope, + McpResultType, McpToolsListBoundaryError, ValidatedMcpToolsListRequest, mcp_tools_list_page, + supported_mcp_tools, +}; + +#[test] +fn mcp_tools_list_page_is_complete_private_and_immediately_stale() { + let page = mcp_tools_list_page(); + + assert_eq!(page.result_type(), McpResultType::Complete); + assert_eq!(page.tools(), supported_mcp_tools()); + assert_eq!(page.ttl_ms(), 0); + assert_eq!(page.cache_scope(), McpCacheScope::Private); + assert_eq!(page.next_cursor(), None); +} + +fn valid_tools_list_request( + cursor: Option<&str>, +) -> Result { + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + cursor, + ) +} + +#[test] +fn mcp_tools_list_request_requires_complete_request_metadata() { + assert_eq!( + valid_tools_list_request(None).map(|validated| validated.method()), + Ok(MCP_TOOLS_LIST_METHOD) + ); + + assert_eq!( + ValidatedMcpToolsListRequest::new( + None, + Some(MCP_PROTOCOL_VERSION), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingProtocolVersionHeader) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + None, + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingProtocolVersionMetadata) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some("2025-11-25"), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some("2025-11-25"), + Some("2025-11-25"), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::UnsupportedProtocolVersion) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + false, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::MissingClientCapabilities) + ); +} + +#[test] +fn mcp_tools_list_bounds_protocol_metadata_before_cross_field_comparison() { + let oversized_protocol_version = format!("{MCP_PROTOCOL_VERSION}0"); + + for (header, metadata) in [ + (oversized_protocol_version.as_str(), MCP_PROTOCOL_VERSION), + (MCP_PROTOCOL_VERSION, oversized_protocol_version.as_str()), + ] { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(header), + Some(metadata), + true, + MCP_TOOLS_LIST_METHOD, + MCP_TOOLS_LIST_METHOD, + None, + ), + Err(McpToolsListBoundaryError::UnsupportedProtocolVersion) + ); + } +} + +#[test] +fn mcp_tools_list_validates_each_method_before_cross_field_comparison() { + let oversized_method = "a".repeat(MAX_MCP_METHOD_NAME_BYTES + 1); + + for (routing_method, body_method) in [ + ("tools list", MCP_TOOLS_LIST_METHOD), + (MCP_TOOLS_LIST_METHOD, "tools list"), + (oversized_method.as_str(), MCP_TOOLS_LIST_METHOD), + (MCP_TOOLS_LIST_METHOD, oversized_method.as_str()), + ] { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + routing_method, + body_method, + None, + ), + Err(McpToolsListBoundaryError::InvalidMethod) + ); + } +} + +#[test] +fn mcp_tools_list_request_requires_exact_routing_and_no_unissued_cursor() { + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + MCP_TOOLS_LIST_METHOD, + "tools/call", + None, + ), + Err(McpToolsListBoundaryError::MethodHeaderBodyMismatch) + ); + assert_eq!( + ValidatedMcpToolsListRequest::new( + Some(MCP_PROTOCOL_VERSION), + Some(MCP_PROTOCOL_VERSION), + true, + "resources/list", + "resources/list", + None, + ), + Err(McpToolsListBoundaryError::UnsupportedMethod) + ); + + for cursor in ["cursor-1", ""] { + assert_eq!( + valid_tools_list_request(Some(cursor)), + Err(McpToolsListBoundaryError::UnsupportedCursor) + ); + } +} + +#[test] +fn mcp_tools_list_request_errors_are_source_free_and_non_echoing() { + let cases = [ + ( + McpToolsListBoundaryError::MissingProtocolVersionHeader, + "MCP protocol version header is required", + ), + ( + McpToolsListBoundaryError::MissingProtocolVersionMetadata, + "MCP request metadata protocol version is required", + ), + ( + McpToolsListBoundaryError::ProtocolVersionHeaderBodyMismatch, + "MCP protocol version header does not match request metadata", + ), + ( + McpToolsListBoundaryError::UnsupportedProtocolVersion, + "unsupported MCP protocol version", + ), + ( + McpToolsListBoundaryError::MissingClientCapabilities, + "MCP request metadata client capabilities are required", + ), + ( + McpToolsListBoundaryError::InvalidMethod, + "MCP method violates the bounded ASCII routing syntax", + ), + ( + McpToolsListBoundaryError::MethodHeaderBodyMismatch, + "MCP method header does not match the request body", + ), + ( + McpToolsListBoundaryError::UnsupportedMethod, + "only MCP tools/list requests can enter the discovery boundary", + ), + ( + McpToolsListBoundaryError::UnsupportedCursor, + "MCP tools/list cursor was not issued by this fixed catalog", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/origin_port_syntax.rs b/crates/originweave-core/tests/origin_port_syntax.rs new file mode 100644 index 000000000..ce58e523e --- /dev/null +++ b/crates/originweave-core/tests/origin_port_syntax.rs @@ -0,0 +1,18 @@ +use originweave_core::{Origin, OriginError}; + +#[test] +fn origin_rejects_non_digit_port_prefixes() { + for input in [ + "https://example.com:+443", + "https://example.com:+8443", + "http://localhost:+80", + "http://127.0.0.1:+8080", + "https://[2001:db8::1]:+443", + ] { + assert_eq!( + Origin::parse(input), + Err(OriginError::InvalidPort), + "input={input}" + ); + } +} diff --git a/crates/originweave-core/tests/release_acceptance.rs b/crates/originweave-core/tests/release_acceptance.rs new file mode 100644 index 000000000..3e37fab18 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance.rs @@ -0,0 +1,397 @@ +use originweave_core::release_acceptance::{ + BenchmarkSuite, BenchmarkSuiteOutcome, DeclaredLimitation, MAX_DECLARED_RELEASE_LIMITATIONS, + ReleaseDecision, ReleaseDecisionError, decide_release, +}; + +fn passing_results() -> Vec<(BenchmarkSuite, BenchmarkSuiteOutcome)> { + BenchmarkSuite::ALL + .into_iter() + .map(|suite| (suite, BenchmarkSuiteOutcome::Passed)) + .collect() +} + +fn declared_limitation() -> Result { + DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is not included in the declared release support profile.", + ) +} + +#[test] +fn generic_constructor_input_shapes_cover_success_paths_in_this_test_crate() { + assert!( + DeclaredLimitation::new(String::from("linux_arm64"), "Linux ARM64 is unsupported.").is_ok() + ); + assert!( + DeclaredLimitation::new("linux_arm64", String::from("Linux ARM64 is unsupported.")).is_ok() + ); +} + +#[test] +fn complete_passing_evidence_is_accepted_without_declared_limitations() +-> Result<(), ReleaseDecisionError> { + let report = decide_release(passing_results(), &[])?; + + assert_eq!(report.decision(), ReleaseDecision::Accepted); + assert!(report.failed_suites().is_empty()); + assert!(report.inconclusive_suites().is_empty()); + assert!(report.missing_suites().is_empty()); + assert!(report.declared_limitations().is_empty()); + Ok(()) +} + +#[test] +fn complete_passing_evidence_preserves_declared_limitation_details() +-> Result<(), ReleaseDecisionError> { + let limitation = declared_limitation()?; + let report = decide_release(passing_results(), std::slice::from_ref(&limitation))?; + + assert_eq!( + report.decision(), + ReleaseDecision::AcceptedWithDeclaredLimitations + ); + assert_eq!(report.declared_limitations(), &[limitation]); + Ok(()) +} + +#[test] +fn limitation_requires_an_unsupported_claim() { + assert_eq!( + DeclaredLimitation::new( + " ", + "A buyer-visible consequence must not stand without the narrowed claim.", + ), + Err(ReleaseDecisionError::EmptyLimitationClaim) + ); +} + +#[test] +fn limitation_requires_a_buyer_visible_consequence() { + assert_eq!( + DeclaredLimitation::new("linux_arm64", "\t\n"), + Err(ReleaseDecisionError::EmptyLimitationConsequence) + ); +} + +#[test] +fn limitation_rejects_control_characters_in_release_metadata() { + assert_eq!( + DeclaredLimitation::new( + "linux_arm64\nforged_release_claim", + "Linux ARM64 is unsupported." + ), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is unsupported.\rforged_release_consequence" + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); +} + +#[test] +fn limitation_rejects_ambiguous_unicode_formatting_characters() { + for character in [ + '\u{00ad}', '\u{061c}', '\u{180e}', '\u{200b}', '\u{200f}', '\u{2028}', '\u{202e}', + '\u{2060}', '\u{2066}', '\u{206f}', '\u{feff}', + ] { + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{character}forged_release_claim"), + "Linux ARM64 is unsupported." + ), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{character}forged_release_consequence") + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); + } +} + +#[test] +fn limitation_preserves_unambiguous_international_buyer_text() -> Result<(), ReleaseDecisionError> { + let limitation = DeclaredLimitation::new( + "한국어_운영환경", + "이 운영환경은 현재 지원 범위에 포함되지 않습니다.", + )?; + + assert_eq!(limitation.unsupported_claim(), "한국어_운영환경"); + assert_eq!( + limitation.buyer_consequence(), + "이 운영환경은 현재 지원 범위에 포함되지 않습니다." + ); + Ok(()) +} + +#[test] +fn limitation_errors_have_deterministic_standard_error_contracts() { + let cases = [ + ( + ReleaseDecisionError::EmptyLimitationClaim, + "declared release limitation must name an unsupported claim", + ), + ( + ReleaseDecisionError::InvalidLimitationClaim, + "declared release limitation claim is not canonical or contains an unsafe presentation character", + ), + ( + ReleaseDecisionError::EmptyLimitationConsequence, + "declared release limitation must state a buyer-visible consequence", + ), + ( + ReleaseDecisionError::InvalidLimitationConsequence, + "declared release limitation consequence is not canonical or contains an unsafe presentation character", + ), + ( + ReleaseDecisionError::DuplicateLimitationClaim, + "benchmark release decision contains duplicate limitation claim", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + let standard_error: &dyn std::error::Error = &error; + assert!(standard_error.source().is_none()); + } +} + +#[test] +fn limitation_exposes_the_exact_narrowed_claim_and_consequence() -> Result<(), ReleaseDecisionError> +{ + let limitation = declared_limitation()?; + + assert_eq!(limitation.unsupported_claim(), "linux_arm64"); + assert_eq!( + limitation.buyer_consequence(), + "Linux ARM64 is not included in the declared release support profile." + ); + Ok(()) +} + +#[test] +fn every_mandatory_suite_is_required_for_acceptance() -> Result<(), ReleaseDecisionError> { + for omitted_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .filter(|(suite, _)| *suite != omitted_suite) + .collect::>(); + + let report = decide_release(evidence, &[])?; + + assert_eq!(report.decision(), ReleaseDecision::Inconclusive); + assert_eq!(report.missing_suites(), &[omitted_suite]); + assert!(report.failed_suites().is_empty()); + } + Ok(()) +} + +#[test] +fn explicit_inconclusive_suite_evidence_cannot_be_promoted_to_acceptance() +-> Result<(), ReleaseDecisionError> { + for inconclusive_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .map(|(suite, outcome)| { + if suite == inconclusive_suite { + (suite, BenchmarkSuiteOutcome::Inconclusive) + } else { + (suite, outcome) + } + }) + .collect::>(); + let limitation = declared_limitation()?; + + let report = decide_release(evidence, std::slice::from_ref(&limitation))?; + + assert_eq!(report.decision(), ReleaseDecision::Inconclusive); + assert_eq!(report.inconclusive_suites(), &[inconclusive_suite]); + assert_eq!(report.declared_limitations(), &[limitation]); + } + Ok(()) +} + +#[test] +fn any_known_threshold_failure_rejects_release_and_identifies_the_suite() +-> Result<(), ReleaseDecisionError> { + for failed_suite in BenchmarkSuite::ALL { + let evidence = passing_results() + .into_iter() + .map(|(suite, outcome)| { + if suite == failed_suite { + (suite, BenchmarkSuiteOutcome::Failed) + } else { + (suite, outcome) + } + }) + .collect::>(); + let limitation = declared_limitation()?; + + let report = decide_release(evidence, std::slice::from_ref(&limitation))?; + + assert_eq!(report.decision(), ReleaseDecision::Rejected); + assert_eq!(report.failed_suites(), &[failed_suite]); + assert_eq!(report.declared_limitations(), &[limitation]); + } + Ok(()) +} + +#[test] +fn known_failure_remains_rejected_when_other_evidence_is_incomplete() +-> Result<(), ReleaseDecisionError> { + let report = decide_release( + vec![ + ( + BenchmarkSuite::ControlledDeterministic, + BenchmarkSuiteOutcome::Failed, + ), + ( + BenchmarkSuite::WebCompatibility, + BenchmarkSuiteOutcome::Inconclusive, + ), + ], + &[], + )?; + + assert_eq!(report.decision(), ReleaseDecision::Rejected); + assert_eq!( + report.failed_suites(), + &[BenchmarkSuite::ControlledDeterministic] + ); + assert_eq!( + report.inconclusive_suites(), + &[BenchmarkSuite::WebCompatibility] + ); + assert_eq!( + report.missing_suites(), + &[ + BenchmarkSuite::SecurityAdversarial, + BenchmarkSuite::ReliabilityRecovery, + BenchmarkSuite::EnterpriseOperability, + ] + ); + Ok(()) +} + +#[test] +fn duplicate_suite_evidence_fails_closed_instead_of_overwriting_results() { + for duplicate_suite in BenchmarkSuite::ALL { + let expected_error = ReleaseDecisionError::DuplicateSuite(duplicate_suite); + assert_eq!( + decide_release( + vec![ + (duplicate_suite, BenchmarkSuiteOutcome::Passed), + (duplicate_suite, BenchmarkSuiteOutcome::Failed), + ], + &[], + ), + Err(expected_error) + ); + + assert_eq!( + expected_error.to_string(), + format!( + "benchmark release evidence contains duplicate suite: {}", + duplicate_suite.as_str() + ) + ); + let standard_error: &dyn std::error::Error = &expected_error; + assert!(standard_error.source().is_none()); + } +} + +#[test] +fn duplicate_suite_evidence_in_vector_input_also_fails_closed() { + let duplicate_suite = BenchmarkSuite::ControlledDeterministic; + let mut evidence = passing_results(); + evidence.push((duplicate_suite, BenchmarkSuiteOutcome::Failed)); + + assert_eq!( + decide_release(evidence, &[]), + Err(ReleaseDecisionError::DuplicateSuite(duplicate_suite)) + ); +} + +#[test] +fn decision_is_independent_of_evidence_input_order() { + let mut reversed = passing_results(); + reversed.reverse(); + + assert_eq!( + decide_release(reversed, &[]), + decide_release(passing_results(), &[]) + ); +} + +#[test] +fn conflicting_consequences_for_one_limitation_claim_fail_closed() +-> Result<(), ReleaseDecisionError> { + let first = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is excluded from the support profile.", + )?; + let conflicting = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is supported only for evaluation deployments.", + )?; + + assert_eq!( + decide_release(passing_results(), &[first, conflicting]), + Err(ReleaseDecisionError::DuplicateLimitationClaim) + ); + Ok(()) +} + +#[test] +fn duplicate_limitation_claim_fails_closed_even_when_consequence_matches() +-> Result<(), ReleaseDecisionError> { + let limitation = declared_limitation()?; + + assert_eq!( + decide_release(passing_results(), &[limitation.clone(), limitation],), + Err(ReleaseDecisionError::DuplicateLimitationClaim) + ); + Ok(()) +} + +#[test] +fn release_report_bounds_declared_limitation_count_before_cloning() +-> Result<(), ReleaseDecisionError> { + let maximum = (0..MAX_DECLARED_RELEASE_LIMITATIONS) + .map(|index| { + DeclaredLimitation::new( + format!("unsupported_profile_{index}"), + "This profile is excluded from the declared support profile.", + ) + }) + .collect::, _>>()?; + let report = decide_release(passing_results(), &maximum)?; + + assert_eq!( + report.decision(), + ReleaseDecision::AcceptedWithDeclaredLimitations + ); + assert_eq!( + report.declared_limitations().len(), + MAX_DECLARED_RELEASE_LIMITATIONS + ); + + let too_many = (0..=MAX_DECLARED_RELEASE_LIMITATIONS) + .map(|index| { + DeclaredLimitation::new( + format!("unsupported_profile_{index}"), + "This profile is excluded from the declared support profile.", + ) + }) + .collect::, _>>()?; + assert_eq!( + decide_release(passing_results(), &too_many), + Err(ReleaseDecisionError::TooManyDeclaredLimitations) + ); + Ok(()) +} diff --git a/crates/originweave-core/tests/release_acceptance_canonical_text.rs b/crates/originweave-core/tests/release_acceptance_canonical_text.rs new file mode 100644 index 000000000..2d7840af3 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_canonical_text.rs @@ -0,0 +1,116 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +#[test] +fn limitation_accepts_canonical_boundary_text() { + let limitation = DeclaredLimitation::new( + "linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ); + + assert_eq!( + limitation + .as_ref() + .map(|value| (value.unsupported_claim(), value.buyer_consequence())), + Ok(( + "linux_arm64", + "Linux ARM64 is excluded from the support profile." + )) + ); +} + +#[test] +fn limitation_rejects_empty_fields_for_the_canonical_string_input_shape() { + assert_eq!( + DeclaredLimitation::new("", "Linux ARM64 is excluded from the support profile."), + Err(ReleaseDecisionError::EmptyLimitationClaim), + ); + assert_eq!( + DeclaredLimitation::new("linux_arm64", ""), + Err(ReleaseDecisionError::EmptyLimitationConsequence), + ); +} + +#[test] +fn limitation_rejects_surrounding_whitespace_that_changes_claim_identity() { + for unsupported_claim in [" linux_arm64", "linux_arm64 ", "\tlinux_arm64"] { + assert_eq!( + DeclaredLimitation::new( + unsupported_claim, + "Linux ARM64 is excluded from the support profile.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "surrounding whitespace must not create a second spelling for one claim identity: {unsupported_claim:?}", + ); + } +} + +#[test] +fn limitation_rejects_surrounding_whitespace_in_buyer_consequence() { + for buyer_consequence in [ + " Linux ARM64 is excluded from the support profile.", + "Linux ARM64 is excluded from the support profile. ", + "Linux ARM64 is excluded from the support profile.\t", + ] { + assert_eq!( + DeclaredLimitation::new("linux_arm64", buyer_consequence), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "buyer-visible consequence must have one canonical boundary spelling: {buyer_consequence:?}", + ); + } +} + +#[test] +fn limitation_rejects_non_nfc_claim_identity() { + let nfc_claim = "caf\u{e9}"; + let canonically_equivalent_nfd_claim = "cafe\u{301}"; + + assert!( + DeclaredLimitation::new( + nfc_claim, + "This normalized claim remains a supported buyer-visible spelling.", + ) + .is_ok(), + "NFC international text must remain admissible", + ); + assert_eq!( + DeclaredLimitation::new( + canonically_equivalent_nfd_claim, + "This decomposed spelling must not create a second claim identity.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "canonically equivalent NFD text must not bypass limitation identity", + ); +} + +#[test] +fn limitation_rejects_non_nfc_buyer_consequence() { + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + "Cafe\u{301} support is excluded from this profile.", + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "buyer-visible consequences must use one canonical Unicode spelling", + ); +} + +#[test] +fn invalid_canonical_text_errors_describe_all_rejected_causes() { + let claim_result = DeclaredLimitation::new( + " linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ); + assert_eq!( + claim_result.as_ref().map_err(ToString::to_string), + Err("declared release limitation claim is not canonical or contains an unsafe presentation character".to_owned()) + ); + + let consequence_result = DeclaredLimitation::new( + "linux_arm64", + "Cafe\u{301} support is excluded from this profile.", + ); + assert_eq!( + consequence_result.as_ref().map_err(ToString::to_string), + Err("declared release limitation consequence is not canonical or contains an unsafe presentation character".to_owned()) + ); +} diff --git a/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs b/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs new file mode 100644 index 000000000..0dfb20ba3 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_meaningful_limitation.rs @@ -0,0 +1,46 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +#[test] +fn punctuation_only_limitation_claim_does_not_name_an_unsupported_claim() { + assert_eq!( + DeclaredLimitation::new("---", "Linux ARM64 is excluded from the support profile."), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); +} + +#[test] +fn punctuation_only_limitation_consequence_does_not_state_a_buyer_consequence() { + assert_eq!( + DeclaredLimitation::new("linux_arm64", "..."), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); +} + +#[test] +fn meaningful_text_may_begin_with_allowed_punctuation() { + assert!( + DeclaredLimitation::new( + "-linux_arm64", + "Linux ARM64 is excluded from the support profile.", + ) + .is_ok() + ); + assert!( + DeclaredLimitation::new( + "linux_arm64", + "... Linux ARM64 remains outside the support profile.", + ) + .is_ok() + ); +} + +#[test] +fn international_alphanumeric_limitation_text_remains_admissible() { + assert!( + DeclaredLimitation::new( + "한국어_운영환경", + "이 운영환경은 현재 지원 범위에 포함되지 않습니다.", + ) + .is_ok() + ); +} diff --git a/crates/originweave-core/tests/release_acceptance_resource_bounds.rs b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs new file mode 100644 index 000000000..fd45e0e6d --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_resource_bounds.rs @@ -0,0 +1,98 @@ +use originweave_core::release_acceptance::{ + DeclaredLimitation, MAX_RELEASE_LIMITATION_TEXT_BYTES, ReleaseDecisionError, +}; + +#[test] +fn limitation_metadata_enforces_exact_utf8_byte_budget() -> Result<(), ReleaseDecisionError> { + let maximum_claim = "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); + let maximum_consequence = "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES); + let limitation = DeclaredLimitation::new(maximum_claim.as_str(), maximum_consequence.as_str())?; + + assert_eq!(limitation.unsupported_claim(), maximum_claim.as_str()); + assert_eq!(limitation.buyer_consequence(), maximum_consequence.as_str()); + + let oversized_claim = "c".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1); + assert_eq!( + DeclaredLimitation::new(oversized_claim.as_str(), "bounded buyer consequence"), + Err(ReleaseDecisionError::LimitationClaimTooLong) + ); + + let oversized_consequence = "x".repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES + 1); + assert_eq!( + DeclaredLimitation::new("bounded_claim", oversized_consequence.as_str()), + Err(ReleaseDecisionError::LimitationConsequenceTooLong) + ); + Ok(()) +} + +#[test] +fn borrowed_limitation_text_covers_every_validation_exit() { + assert_eq!( + DeclaredLimitation::new("", "bounded buyer consequence"), + Err(ReleaseDecisionError::EmptyLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new(" bounded_claim", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new("cafe\u{301}", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", ""), + Err(ReleaseDecisionError::EmptyLimitationConsequence) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "bounded buyer consequence "), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "cafe\u{301} buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); + assert_eq!( + DeclaredLimitation::new("forged\nclaim", "bounded buyer consequence"), + Err(ReleaseDecisionError::InvalidLimitationClaim) + ); + assert_eq!( + DeclaredLimitation::new("bounded_claim", "forged\nconsequence"), + Err(ReleaseDecisionError::InvalidLimitationConsequence) + ); +} + +#[test] +fn limitation_byte_budget_applies_to_international_text() { + let korean_character = "가"; + let repeated = + korean_character.repeat(MAX_RELEASE_LIMITATION_TEXT_BYTES / korean_character.len() + 1); + assert!(repeated.len() > MAX_RELEASE_LIMITATION_TEXT_BYTES); + assert_eq!( + DeclaredLimitation::new(repeated.as_str(), "지원 범위를 설명하는 구매자 안내"), + Err(ReleaseDecisionError::LimitationClaimTooLong) + ); +} + +#[test] +fn release_resource_limit_errors_have_deterministic_standard_error_contracts() { + let cases = [ + ( + ReleaseDecisionError::LimitationClaimTooLong, + "declared release limitation claim exceeds the byte budget", + ), + ( + ReleaseDecisionError::LimitationConsequenceTooLong, + "declared release limitation consequence exceeds the byte budget", + ), + ( + ReleaseDecisionError::TooManyDeclaredLimitations, + "benchmark release decision contains too many declared limitations", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + let standard_error: &dyn std::error::Error = &error; + assert!(standard_error.source().is_none()); + } +} diff --git a/crates/originweave-core/tests/release_acceptance_unicode17.rs b/crates/originweave-core/tests/release_acceptance_unicode17.rs new file mode 100644 index 000000000..eccd90e89 --- /dev/null +++ b/crates/originweave-core/tests/release_acceptance_unicode17.rs @@ -0,0 +1,121 @@ +use originweave_core::release_acceptance::{DeclaredLimitation, ReleaseDecisionError}; + +const UNICODE_17_DEFAULT_IGNORABLE_CODE_POINT_COUNT: usize = 4_174; + +#[test] +fn generic_constructor_input_shapes_cover_fail_closed_empty_boundaries() { + assert_eq!( + DeclaredLimitation::new(String::new(), "Linux ARM64 is unsupported."), + Err(ReleaseDecisionError::EmptyLimitationClaim), + ); + assert!( + DeclaredLimitation::new(String::from("linux_arm64"), "Linux ARM64 is unsupported.").is_ok() + ); + assert_eq!( + DeclaredLimitation::new("linux_arm64", String::new()), + Err(ReleaseDecisionError::EmptyLimitationConsequence), + ); + assert!( + DeclaredLimitation::new("linux_arm64", String::from("Linux ARM64 is unsupported.")).is_ok() + ); +} + +#[test] +fn limitation_rejects_unicode_17_default_ignorable_code_points() -> Result<(), &'static str> { + // Unicode 17.0.0 DerivedCoreProperties.txt (2025-07-30), + // Default_Ignorable_Code_Point. The reviewed ranges contain exactly 4,174 code points. + let ranges = [ + (0x00ad_u32, 0x00ad_u32), + (0x034f, 0x034f), + (0x061c, 0x061c), + (0x115f, 0x1160), + (0x17b4, 0x17b5), + (0x180b, 0x180f), + (0x200b, 0x200f), + (0x202a, 0x202e), + (0x2060, 0x206f), + (0x3164, 0x3164), + (0xfe00, 0xfe0f), + (0xfeff, 0xfeff), + (0xffa0, 0xffa0), + (0xfff0, 0xfff8), + (0x1bca0, 0x1bca3), + (0x1d173, 0x1d17a), + (0xe0000, 0xe0fff), + ]; + let mut tested_code_points = 0_usize; + + for (start, end) in ranges { + for code_point in start..=end { + let character = char::from_u32(code_point) + .ok_or("reviewed Unicode 17 default-ignorable range must contain scalar values")?; + tested_code_points += 1; + + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{character}forged_release_claim"), + "Linux ARM64 is unsupported.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "U+{code_point:04X} must be rejected in the unsupported claim", + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{character}forged_release_consequence"), + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "U+{code_point:04X} must be rejected in the buyer consequence", + ); + } + } + + assert_eq!( + tested_code_points, UNICODE_17_DEFAULT_IGNORABLE_CODE_POINT_COUNT, + "reviewed Unicode 17 Default_Ignorable_Code_Point ranges must match the authoritative cardinality", + ); + Ok(()) +} + +#[test] +fn limitation_rejects_line_and_paragraph_separators_beyond_default_ignorable_set() { + for (name, separator) in [("U+2028", '\u{2028}'), ("U+2029", '\u{2029}')] { + assert_eq!( + DeclaredLimitation::new( + format!("linux_arm64{separator}forged_release_claim"), + "Linux ARM64 is unsupported.", + ), + Err(ReleaseDecisionError::InvalidLimitationClaim), + "{name} must be rejected in the unsupported claim to prevent line-forging ambiguity", + ); + assert_eq!( + DeclaredLimitation::new( + "linux_arm64", + format!("Linux ARM64 is unsupported.{separator}forged_release_consequence"), + ), + Err(ReleaseDecisionError::InvalidLimitationConsequence), + "{name} must be rejected in the buyer consequence to prevent line-forging ambiguity", + ); + } +} + +#[test] +fn limitation_does_not_blanket_reject_unicode_17_whitespace() -> Result<(), ReleaseDecisionError> { + let medium_mathematical_space = '\u{205f}'; + let ideographic_space = '\u{3000}'; + + let limitation = DeclaredLimitation::new( + format!("east{ideographic_space}asia"), + format!("Support is limited{medium_mathematical_space}to the declared profile."), + )?; + + assert_eq!( + limitation.unsupported_claim(), + format!("east{ideographic_space}asia") + ); + assert_eq!( + limitation.buyer_consequence(), + format!("Support is limited{medium_mathematical_space}to the declared profile.") + ); + Ok(()) +} diff --git a/crates/originweave-destination/src/lib.rs b/crates/originweave-destination/src/lib.rs index 5fdf2d363..774ba9ee9 100644 --- a/crates/originweave-destination/src/lib.rs +++ b/crates/originweave-destination/src/lib.rs @@ -24,6 +24,7 @@ pub use redirect::{ RedirectTargetDigestError, }; pub use resolution::{ - ConnectionEvidence, DestinationError, DestinationPolicy, MAX_RESOLUTION_ADDRESS_COUNT, + ConnectionEvidence, DestinationError, DestinationPolicy, FreshConnectionEvidence, + FreshResolutionSnapshot, MAX_RESOLUTION_ADDRESS_COUNT, MAX_RESOLUTION_VALIDITY, ResolutionSnapshot, }; diff --git a/crates/originweave-destination/src/proxy.rs b/crates/originweave-destination/src/proxy.rs index ef64289fa..4695dc3aa 100644 --- a/crates/originweave-destination/src/proxy.rs +++ b/crates/originweave-destination/src/proxy.rs @@ -446,6 +446,9 @@ fn explicit_port(authority: &str) -> Result, ProxyServerError> { port }; + if !port_text.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(ProxyServerError::InvalidIdentifier); + } let port = port_text .parse::() .map_err(|_error| ProxyServerError::InvalidIdentifier)?; diff --git a/crates/originweave-destination/src/resolution.rs b/crates/originweave-destination/src/resolution.rs index f55d1722b..45620e6cd 100644 --- a/crates/originweave-destination/src/resolution.rs +++ b/crates/originweave-destination/src/resolution.rs @@ -1,6 +1,7 @@ use std::collections::BTreeSet; use std::fmt; use std::net::IpAddr; +use std::time::Duration; use originweave_core::Origin; @@ -9,6 +10,13 @@ use crate::{AddressClass, ClassifiedAddress, classify_address}; /// The largest resolver answer accepted by one resolution snapshot. pub const MAX_RESOLUTION_ADDRESS_COUNT: usize = 256; +/// The largest freshness interval accepted for one resolution approval. +/// +/// This is an OriginWeave product safety budget, not a DNS protocol validity +/// rule. Callers may choose any smaller non-zero interval appropriate to their +/// resolver and network adapter. +pub const MAX_RESOLUTION_VALIDITY: Duration = Duration::from_secs(30); + /// A fail-closed allow-list of destination address classes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DestinationPolicy { @@ -103,6 +111,34 @@ pub enum DestinationError { /// The newly introduced canonical address. address: IpAddr, }, + /// A freshness interval was zero or exceeded [`MAX_RESOLUTION_VALIDITY`]. + InvalidResolutionValidity { + /// The rejected freshness interval. + validity: Duration, + /// The largest accepted freshness interval. + maximum_validity: Duration, + }, + /// Adding the freshness interval to the approval time overflowed. + ResolutionValidityOverflow { + /// The trusted monotonic time at which the answer was approved. + approved_at: Duration, + /// The requested freshness interval. + validity: Duration, + }, + /// A caller supplied a monotonic time earlier than the recorded approval. + ResolutionUseBeforeApproval { + /// The recorded approval time. + approved_at: Duration, + /// The caller-supplied current time. + current_time: Duration, + }, + /// A bounded resolution approval reached its exclusive validity deadline. + ResolutionApprovalExpired { + /// The exclusive upper bound of the approval interval. + valid_until: Duration, + /// The caller-supplied current time. + current_time: Duration, + }, } impl fmt::Display for DestinationError { @@ -142,6 +178,34 @@ impl fmt::Display for DestinationError { formatter, "refreshed DNS answer introduced unapproved address {address}", ), + Self::InvalidResolutionValidity { + validity, + maximum_validity, + } => write!( + formatter, + "resolution validity {validity:?} is outside 1ns..={maximum_validity:?}", + ), + Self::ResolutionValidityOverflow { + approved_at, + validity, + } => write!( + formatter, + "resolution validity {validity:?} overflows approval time {approved_at:?}", + ), + Self::ResolutionUseBeforeApproval { + approved_at, + current_time, + } => write!( + formatter, + "resolution use time {current_time:?} precedes approval time {approved_at:?}", + ), + Self::ResolutionApprovalExpired { + valid_until, + current_time, + } => write!( + formatter, + "resolution approval expired at {valid_until:?}; current time is {current_time:?}", + ), } } } @@ -254,6 +318,143 @@ impl ResolutionSnapshot { } } +/// A resolution snapshot bound to one explicit trusted monotonic validity window. +/// +/// The time values are opaque durations from one caller-owned monotonic clock +/// domain. This type never reads a wall clock itself. Constructing a new fresh +/// snapshot always reruns the same destination validation used by +/// [`ResolutionSnapshot`], so callers cannot renew authority without presenting +/// another policy-valid answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreshResolutionSnapshot { + snapshot: ResolutionSnapshot, + approved_at: Duration, + validity: Duration, + valid_until: Duration, +} + +impl FreshResolutionSnapshot { + /// Validate addresses and bind the resulting snapshot to a bounded lifetime. + pub fn approve( + origin: Origin, + addresses: impl IntoIterator, + policy: &DestinationPolicy, + approved_at: Duration, + validity: Duration, + ) -> Result { + let snapshot = ResolutionSnapshot::approve(origin, addresses, policy)?; + Self::from_snapshot(snapshot, approved_at, validity) + } + + fn from_snapshot( + snapshot: ResolutionSnapshot, + approved_at: Duration, + validity: Duration, + ) -> Result { + if validity.is_zero() || validity > MAX_RESOLUTION_VALIDITY { + return Err(DestinationError::InvalidResolutionValidity { + validity, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }); + } + let Some(valid_until) = approved_at.checked_add(validity) else { + return Err(DestinationError::ResolutionValidityOverflow { + approved_at, + validity, + }); + }; + Ok(Self { + snapshot, + approved_at, + validity, + valid_until, + }) + } + + /// Return the logical origin whose DNS answer was approved. + #[must_use] + pub const fn origin(&self) -> &Origin { + self.snapshot.origin() + } + + /// Return the canonical addresses pinned for this fresh snapshot. + #[must_use] + pub const fn addresses(&self) -> &BTreeSet { + self.snapshot.addresses() + } + + /// Return the trusted monotonic approval time. + #[must_use] + pub const fn approved_at(&self) -> Duration { + self.approved_at + } + + /// Return the configured non-zero validity budget. + #[must_use] + pub const fn validity(&self) -> Duration { + self.validity + } + + /// Return the exclusive upper bound of the approval interval. + #[must_use] + pub const fn valid_until(&self) -> Duration { + self.valid_until + } + + /// Authorize one pinned address only while the freshness window is valid. + pub fn authorize_connection( + &self, + address: IpAddr, + current_time: Duration, + ) -> Result { + self.validate_current_time(current_time)?; + let connection = self.snapshot.authorize_connection(address)?; + Ok(FreshConnectionEvidence { + connection, + resolution_approved_at: self.approved_at, + resolution_valid_until: self.valid_until, + authorized_at: current_time, + }) + } + + /// Revalidate a fresh answer and renew the same bounded validity budget. + /// + /// `revalidated_at` must come from the same monotonic clock domain and may + /// not precede this snapshot's approval time. Expansion of the pinned set + /// remains fail-closed under [`ResolutionSnapshot::revalidate`]. + pub fn revalidate( + &self, + addresses: impl IntoIterator, + policy: &DestinationPolicy, + revalidated_at: Duration, + ) -> Result { + if revalidated_at < self.approved_at { + return Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: self.approved_at, + current_time: revalidated_at, + }); + } + let snapshot = self.snapshot.revalidate(addresses, policy)?; + Self::from_snapshot(snapshot, revalidated_at, self.validity) + } + + fn validate_current_time(&self, current_time: Duration) -> Result<(), DestinationError> { + if current_time < self.approved_at { + return Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: self.approved_at, + current_time, + }); + } + if current_time >= self.valid_until { + return Err(DestinationError::ResolutionApprovalExpired { + valid_until: self.valid_until, + current_time, + }); + } + Ok(()) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OriginHostConstraint { Domain, @@ -344,3 +545,38 @@ impl ConnectionEvidence { self.address_class } } + +/// Credential-free evidence that a pinned connection address was used while fresh. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreshConnectionEvidence { + connection: ConnectionEvidence, + resolution_approved_at: Duration, + resolution_valid_until: Duration, + authorized_at: Duration, +} + +impl FreshConnectionEvidence { + /// Return the underlying canonical destination/connection evidence. + #[must_use] + pub const fn connection_evidence(&self) -> &ConnectionEvidence { + &self.connection + } + + /// Return the trusted monotonic time at which the answer was approved. + #[must_use] + pub const fn resolution_approved_at(&self) -> Duration { + self.resolution_approved_at + } + + /// Return the exclusive upper bound of the resolution approval interval. + #[must_use] + pub const fn resolution_valid_until(&self) -> Duration { + self.resolution_valid_until + } + + /// Return the trusted monotonic time used for this authorization decision. + #[must_use] + pub const fn authorized_at(&self) -> Duration { + self.authorized_at + } +} diff --git a/crates/originweave-destination/tests/proxy_port_syntax.rs b/crates/originweave-destination/tests/proxy_port_syntax.rs new file mode 100644 index 000000000..9038c14ed --- /dev/null +++ b/crates/originweave-destination/tests/proxy_port_syntax.rs @@ -0,0 +1,29 @@ +use originweave_destination::{ProxyServer, ProxyServerError}; + +#[test] +fn proxy_server_rejects_non_digit_port_prefixes() { + for input in [ + "proxy.example:+8080", + "http://proxy.example:+8080", + "https://proxy.example:+8443", + "socks5://proxy.example:+1080", + "https://[2001:db8::1]:+8443", + ] { + assert_eq!( + ProxyServer::parse(input), + Err(ProxyServerError::InvalidIdentifier), + "input={input}", + ); + } +} + +#[test] +fn proxy_server_rejects_decimal_ports_outside_u16_range() { + for input in ["proxy.example:65536", "https://[2001:db8::1]:65536"] { + assert_eq!( + ProxyServer::parse(input), + Err(ProxyServerError::InvalidIdentifier), + "input={input}", + ); + } +} diff --git a/crates/originweave-destination/tests/resolution_freshness.rs b/crates/originweave-destination/tests/resolution_freshness.rs new file mode 100644 index 000000000..2df264563 --- /dev/null +++ b/crates/originweave-destination/tests/resolution_freshness.rs @@ -0,0 +1,235 @@ +#![allow(clippy::expect_used)] + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use originweave_core::Origin; +use originweave_destination::{ + AddressClass, DestinationError, DestinationPolicy, FreshResolutionSnapshot, + MAX_RESOLUTION_VALIDITY, +}; + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("test origin must parse") +} + +fn ipv4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) +} + +#[test] +fn fresh_resolution_authority_is_half_open_and_bound_to_pinned_addresses() { + let approved_at = Duration::from_secs(100); + let validity = Duration::from_secs(5); + let target = origin("https://example.com"); + let approved = ipv4(8, 8, 8, 8); + let snapshot = FreshResolutionSnapshot::approve( + target.clone(), + [approved], + &DestinationPolicy::public_web(), + approved_at, + validity, + ) + .expect("bounded fresh resolution"); + + assert_eq!(snapshot.origin(), &target); + assert_eq!(snapshot.approved_at(), approved_at); + assert_eq!(snapshot.validity(), validity); + assert_eq!(snapshot.valid_until(), Duration::from_secs(105)); + + let evidence = snapshot + .authorize_connection(approved, approved_at) + .expect("authority begins at approval time"); + let connection = evidence.connection_evidence(); + assert_eq!(connection.origin(), &target); + assert_eq!(connection.requested_address(), approved); + assert_eq!(connection.canonical_address(), approved); + assert_eq!(connection.address_class(), AddressClass::Public); + assert_eq!(evidence.resolution_approved_at(), approved_at); + assert_eq!(evidence.resolution_valid_until(), Duration::from_secs(105)); + assert_eq!(evidence.authorized_at(), approved_at); + + snapshot + .authorize_connection(approved, Duration::from_secs(104)) + .expect("authority remains valid before the exclusive deadline"); + + assert_eq!( + snapshot.authorize_connection(approved, Duration::from_secs(99)), + Err(DestinationError::ResolutionUseBeforeApproval { + approved_at, + current_time: Duration::from_secs(99), + }) + ); + assert_eq!( + snapshot.authorize_connection(approved, Duration::from_secs(105)), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: Duration::from_secs(105), + current_time: Duration::from_secs(105), + }) + ); + assert_eq!( + snapshot.authorize_connection(ipv4(9, 9, 9, 9), approved_at), + Err(DestinationError::UnapprovedConnectionAddress { + address: ipv4(9, 9, 9, 9), + }) + ); +} + +#[test] +fn fresh_resolution_rejects_invalid_or_overflowing_validity() { + let target = origin("https://example.com"); + let address = ipv4(8, 8, 8, 8); + let policy = DestinationPolicy::public_web(); + + for validity in [ + Duration::ZERO, + MAX_RESOLUTION_VALIDITY + Duration::from_nanos(1), + ] { + assert_eq!( + FreshResolutionSnapshot::approve( + target.clone(), + [address], + &policy, + Duration::from_secs(1), + validity, + ), + Err(DestinationError::InvalidResolutionValidity { + validity, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }) + ); + } + + assert_eq!( + FreshResolutionSnapshot::approve( + target, + [address], + &policy, + Duration::MAX, + Duration::from_nanos(1), + ), + Err(DestinationError::ResolutionValidityOverflow { + approved_at: Duration::MAX, + validity: Duration::from_nanos(1), + }) + ); +} + +#[test] +fn fresh_resolution_rejects_denied_addresses_before_granting_time_authority() { + let target = origin("https://example.com"); + let denied = ipv4(127, 0, 0, 1); + let public = ipv4(8, 8, 8, 8); + let policy = DestinationPolicy::public_web(); + let expected = Err(DestinationError::AddressClassDenied { + address: denied, + address_class: AddressClass::Loopback, + }); + + assert_eq!( + FreshResolutionSnapshot::approve( + target.clone(), + [denied], + &policy, + Duration::from_secs(1), + Duration::from_secs(1), + ), + expected.clone() + ); + assert_eq!( + FreshResolutionSnapshot::approve( + target, + [denied, public], + &policy, + Duration::from_secs(1), + Duration::from_secs(1), + ), + expected + ); +} + +#[test] +fn fresh_revalidation_preserves_the_budget_and_resets_approval_time() { + let first = ipv4(8, 8, 8, 8); + let second = ipv4(1, 1, 1, 1); + let unexpected = ipv4(9, 9, 9, 9); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin("https://example.com"), + [first, second], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial fresh resolution"); + + let refreshed = snapshot + .revalidate([second], &policy, Duration::from_secs(13)) + .expect("a fresh non-expanding answer renews the bounded window"); + assert_eq!( + refreshed.addresses(), + &std::collections::BTreeSet::from([second]) + ); + assert_eq!(refreshed.approved_at(), Duration::from_secs(13)); + assert_eq!(refreshed.validity(), Duration::from_secs(4)); + assert_eq!(refreshed.valid_until(), Duration::from_secs(17)); + refreshed + .authorize_connection(second, Duration::from_secs(16)) + .expect("refreshed authority is usable before its new deadline"); + + assert_eq!( + snapshot.revalidate([second], &policy, Duration::from_secs(9)), + Err(DestinationError::ResolutionUseBeforeApproval { + approved_at: Duration::from_secs(10), + current_time: Duration::from_secs(9), + }) + ); + assert_eq!( + snapshot.revalidate([unexpected], &policy, Duration::from_secs(11)), + Err(DestinationError::ResolutionSetExpanded { + address: unexpected, + }) + ); + assert_eq!( + snapshot.revalidate([first, unexpected], &policy, Duration::from_secs(11)), + Err(DestinationError::ResolutionSetExpanded { + address: unexpected, + }) + ); +} + +#[test] +fn freshness_errors_have_deterministic_bounded_messages() { + let invalid = DestinationError::InvalidResolutionValidity { + validity: Duration::ZERO, + maximum_validity: MAX_RESOLUTION_VALIDITY, + }; + assert_eq!( + invalid.to_string(), + "resolution validity 0ns is outside 1ns..=30s" + ); + + let overflow = DestinationError::ResolutionValidityOverflow { + approved_at: Duration::MAX, + validity: Duration::from_nanos(1), + }; + assert!(overflow.to_string().contains("overflows approval time")); + + let before = DestinationError::ResolutionUseBeforeApproval { + approved_at: Duration::from_secs(10), + current_time: Duration::from_secs(9), + }; + assert_eq!( + before.to_string(), + "resolution use time 9s precedes approval time 10s" + ); + + let expired = DestinationError::ResolutionApprovalExpired { + valid_until: Duration::from_secs(15), + current_time: Duration::from_secs(15), + }; + assert_eq!( + expired.to_string(), + "resolution approval expired at 15s; current time is 15s" + ); +} diff --git a/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs b/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs new file mode 100644 index 000000000..3c8443554 --- /dev/null +++ b/crates/originweave-destination/tests/resolution_post_expiry_revalidation.rs @@ -0,0 +1,78 @@ +#![allow(clippy::expect_used)] + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use originweave_core::Origin; +use originweave_destination::{DestinationError, DestinationPolicy, FreshResolutionSnapshot}; + +fn origin() -> Origin { + Origin::parse("https://example.com").expect("test origin must parse") +} + +fn ipv4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) +} + +#[test] +fn post_expiry_revalidation_establishes_new_authority_without_reviving_the_old_snapshot() { + let first = ipv4(8, 8, 8, 8); + let second = ipv4(1, 1, 1, 1); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin(), + [first, second], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial bounded freshness authority"); + + let expiry = Duration::from_secs(14); + assert_eq!( + snapshot.authorize_connection(first, expiry), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: expiry, + current_time: expiry, + }) + ); + + let refreshed = snapshot + .revalidate([second], &policy, expiry) + .expect("fresh non-expanding validation may establish a new bounded snapshot"); + assert_eq!(refreshed.approved_at(), expiry); + assert_eq!(refreshed.valid_until(), Duration::from_secs(18)); + refreshed + .authorize_connection(second, expiry) + .expect("the newly validated snapshot has independent current authority"); + + assert_eq!( + snapshot.authorize_connection(second, expiry), + Err(DestinationError::ResolutionApprovalExpired { + valid_until: expiry, + current_time: expiry, + }) + ); +} + +#[test] +fn post_expiry_revalidation_still_rejects_address_set_expansion() { + let approved = ipv4(8, 8, 8, 8); + let unexpected = ipv4(9, 9, 9, 9); + let policy = DestinationPolicy::public_web(); + let snapshot = FreshResolutionSnapshot::approve( + origin(), + [approved], + &policy, + Duration::from_secs(10), + Duration::from_secs(4), + ) + .expect("initial bounded freshness authority"); + + assert_eq!( + snapshot.revalidate([approved, unexpected], &policy, Duration::from_secs(14)), + Err(DestinationError::ResolutionSetExpanded { + address: unexpected, + }) + ); +} diff --git a/crates/originweave-policy/src/lib.rs b/crates/originweave-policy/src/lib.rs index 243ae8ce7..dbfb3c16d 100644 --- a/crates/originweave-policy/src/lib.rs +++ b/crates/originweave-policy/src/lib.rs @@ -15,6 +15,7 @@ pub use sensitive_data::{ evaluate_handle_use, }; +use originweave_core::mcp::ValidatedMcpToolCall; use originweave_core::{ ActionRequest, ApprovalEvidence, ApprovalScope, Capability, ExecutionPurpose, InstructionSource, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, SessionMode, @@ -40,6 +41,8 @@ pub enum DenialReason { ModePurposeMismatch, /// Page or document content attempted to become a trusted instruction. UntrustedInstructionSource, + /// The validated MCP route resolved to a different action than the policy request. + McpActionMismatch, /// The session lacks the exact capability required by the action. MissingCapability(Capability), /// The target origin is outside the session's read grant. @@ -66,6 +69,23 @@ pub enum DenialReason { ApprovalScopeMismatch, } +/// Evaluate a policy request only when it matches an already validated MCP route. +/// +/// Matching routing metadata grants no authority. Once route and request action agree, the request +/// still passes through the existing action policy unchanged. +#[must_use] +pub fn evaluate_mcp( + call: &ValidatedMcpToolCall, + request: &ActionRequest, + context: &PolicyContext, +) -> Decision { + if call.action_kind() != request.action() { + return Decision::Deny(DenialReason::McpActionMismatch); + } + + evaluate(request, context) +} + /// Evaluate a typed browser action against one explicit policy context. #[must_use] pub fn evaluate(request: &ActionRequest, context: &PolicyContext) -> Decision { diff --git a/crates/originweave-policy/tests/extension_mutation_isolation.rs b/crates/originweave-policy/tests/extension_mutation_isolation.rs new file mode 100644 index 000000000..48d7936e1 --- /dev/null +++ b/crates/originweave-policy/tests/extension_mutation_isolation.rs @@ -0,0 +1,343 @@ +#![allow(clippy::expect_used)] + +//! Keep extension proposal-grant evaluation separate from ordinary action policy. +//! +//! OriginWeave does not yet implement an adapter that converts an extension proposal into an +//! [`ActionRequest`]. These regressions therefore prove two independent fail-closed boundaries: +//! the exact extension/session/context/origin/unexpired grant permits only `ProposeTypedAction`, +//! while an ordinary user-sourced action request remains subject to the core policy decision +//! shown in each test. + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, + evaluate_extension_access, +}; +use originweave_policy::{Decision, DenialReason, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EXTENSION_ORIGIN: &str = "https://extension.example"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(17).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(23).expect("nonzero browsing context") +} + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_proposal_grant_is_independently_allowed(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_cross_origin_mutation_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let source = origin("https://source.example"); + let target = origin("https://target.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([source.clone(), target.clone()]), + BTreeSet::from([target.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + source, + target, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::CrossOriginMutation) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_write_origin_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::OriginNotWritable) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_crawler_mutation_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Submit]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Submit, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::CrawlerMutation) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_mode_purpose_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::ModePurposeMismatch) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_disallowed_robots_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Disallowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsDisallowed) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_unknown_robots_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Unknown, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsUnknown) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_missing_robots_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://public.example"); + let context = PolicyContext::new( + SessionMode::Crawler, + ExecutionPurpose::PublicCrawl, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::NotApplicable, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Observe, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::RobotsNotApplicable) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_non_delegable_r5_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://consent.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::LegalConsent]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::LegalConsent, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::ForbiddenRisk) + ); +} + +#[test] +fn extension_proposal_grant_is_independent_of_human_mode_policy() { + let grant = action_proposal_grant(); + assert_proposal_grant_is_independently_allowed(&grant); + + let site = origin("https://human.example"); + let context = PolicyContext::new( + SessionMode::Human, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::NotApplicable, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::HumanModeNotAgentControlled) + ); +} diff --git a/crates/originweave-policy/tests/extension_policy_isolation.rs b/crates/originweave-policy/tests/extension_policy_isolation.rs new file mode 100644 index 000000000..f32d8733c --- /dev/null +++ b/crates/originweave-policy/tests/extension_policy_isolation.rs @@ -0,0 +1,215 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, + evaluate_extension_access, +}; +use originweave_policy::{Decision, DenialReason, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const EXTENSION_ORIGIN: &str = "https://extension.example"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(7).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(11).expect("nonzero browsing context") +} + +fn origin(value: &str) -> Origin { + Origin::parse(value).expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_extension_can_only_propose(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + browser_session(), + browsing_context(), + origin(EXTENSION_ORIGIN), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +#[test] +fn explicit_extension_grant_does_not_widen_agent_origin_authority() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let allowed = origin("https://app.example"); + let forbidden = origin("https://outside.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([allowed.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + allowed, + forbidden, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::OriginNotReadable) + ); +} + +#[test] +fn explicit_extension_grant_does_not_supply_agent_action_capability() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Observe]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::MissingCapability(Capability::Navigate)) + ); +} + +#[test] +fn untrusted_extension_content_cannot_become_a_policy_instruction() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::WebContent, + SecretDelivery::None, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::UntrustedInstructionSource) + ); +} + +#[test] +fn explicit_extension_grant_cannot_turn_raw_secret_delivery_into_a_fill_capability() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::FillSecret]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::FillSecret, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::RawValue, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::SecretBrokerRequired) + ); +} + +#[test] +fn explicit_extension_grant_cannot_attach_secret_material_to_non_secret_action() { + let grant = action_proposal_grant(); + assert_extension_can_only_propose(&grant); + + let site = origin("https://app.example"); + let context = PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::Navigate]), + BTreeSet::from([site.clone()]), + BTreeSet::new(), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ); + let proposed = ActionRequest::new( + ActionKind::Navigate, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::RawValue, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &context), + Decision::Deny(DenialReason::UnexpectedSecretMaterial) + ); +} diff --git a/crates/originweave-policy/tests/extension_secret_isolation.rs b/crates/originweave-policy/tests/extension_secret_isolation.rs new file mode 100644 index 000000000..f808bec04 --- /dev/null +++ b/crates/originweave-policy/tests/extension_secret_isolation.rs @@ -0,0 +1,96 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, BrowserSessionId, + BrowsingContextId, Capability, ExecutionPurpose, ExtensionAccessDecision, + ExtensionAccessRequest, ExtensionAgentCapability, ExtensionAgentGrant, ExtensionId, + InstructionSource, Origin, PolicyContext, RiskClass, RobotsDecision, SecretDelivery, + SessionMode, evaluate_extension_access, +}; +use originweave_policy::{Decision, evaluate}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const UNEXPIRED_NOW_EPOCH_SECONDS: u64 = 1_700_000_000; +const UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS: u64 = 1_700_000_600; + +fn extension_id() -> ExtensionId { + ExtensionId::parse("abcdefghijklmnopabcdefghijklmnop").expect("valid extension id") +} + +fn browser_session() -> BrowserSessionId { + BrowserSessionId::new(7).expect("nonzero browser session") +} + +fn browsing_context() -> BrowsingContextId { + BrowsingContextId::new(11).expect("nonzero browsing context") +} + +fn origin() -> Origin { + Origin::parse("https://login.example").expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn action_proposal_grant() -> ExtensionAgentGrant { + ExtensionAgentGrant::new( + extension_id(), + browser_session(), + browsing_context(), + origin(), + UNEXPIRED_EXPIRES_AT_EPOCH_SECONDS, + [ExtensionAgentCapability::ProposeTypedAction], + ) +} + +fn assert_extension_can_propose(grant: &ExtensionAgentGrant) { + let request = ExtensionAccessRequest::new( + extension_id(), + browser_session(), + browsing_context(), + origin(), + UNEXPIRED_NOW_EPOCH_SECONDS, + ExtensionAgentCapability::ProposeTypedAction, + ); + assert_eq!( + evaluate_extension_access(&request, Some(grant)), + ExtensionAccessDecision::Allow + ); +} + +fn secret_context(site: &Origin) -> PolicyContext { + PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + BTreeSet::from([Capability::FillSecret]), + BTreeSet::from([site.clone()]), + BTreeSet::from([site.clone()]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ) +} + +#[test] +fn extension_action_grant_cannot_skip_secret_broker_approval() { + let grant = action_proposal_grant(); + assert_extension_can_propose(&grant); + + let site = origin(); + let proposed = ActionRequest::new( + ActionKind::FillSecret, + site.clone(), + site.clone(), + InstructionSource::User, + SecretDelivery::BrokerHandle, + intent(), + ); + + assert_eq!( + evaluate(&proposed, &secret_context(&site)), + Decision::RequireApproval(RiskClass::R3) + ); +} diff --git a/crates/originweave-policy/tests/mcp_route_binding.rs b/crates/originweave-policy/tests/mcp_route_binding.rs new file mode 100644 index 000000000..8e9661af6 --- /dev/null +++ b/crates/originweave-policy/tests/mcp_route_binding.rs @@ -0,0 +1,96 @@ +#![allow(clippy::expect_used)] + +use std::collections::BTreeSet; + +use originweave_core::mcp::{MCP_PROTOCOL_VERSION, MCP_TOOLS_CALL_METHOD, ValidatedMcpToolCall}; +use originweave_core::{ + ActionIntentDigest, ActionKind, ActionRequest, ApprovalEvidence, Capability, ExecutionPurpose, + InstructionSource, Origin, PolicyContext, RobotsDecision, SecretDelivery, SessionMode, +}; +use originweave_policy::{Decision, DenialReason, evaluate_mcp}; + +const VALID_INTENT: &str = + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn origin() -> Origin { + Origin::parse("https://mcp.example").expect("valid test origin") +} + +fn intent() -> ActionIntentDigest { + ActionIntentDigest::parse(VALID_INTENT).expect("valid intent digest") +} + +fn validated_call(tool_name: &str) -> ValidatedMcpToolCall { + ValidatedMcpToolCall::new( + MCP_PROTOCOL_VERSION, + MCP_TOOLS_CALL_METHOD, + tool_name, + MCP_TOOLS_CALL_METHOD, + tool_name, + ) + .expect("known test MCP tool") +} + +fn request(action: ActionKind) -> ActionRequest { + let site = origin(); + ActionRequest::new( + action, + site.clone(), + site, + InstructionSource::User, + SecretDelivery::None, + intent(), + ) +} + +fn context(capabilities: BTreeSet) -> PolicyContext { + let site = origin(); + PolicyContext::new( + SessionMode::AgentTask, + ExecutionPurpose::UserDelegatedTask, + capabilities, + BTreeSet::from([site.clone()]), + BTreeSet::from([site]), + RobotsDecision::Allowed, + ApprovalEvidence::None, + ) +} + +#[test] +fn matching_mcp_route_enters_the_existing_policy_boundary() { + let call = validated_call("originweave.observe"); + let decision = evaluate_mcp( + &call, + &request(ActionKind::Observe), + &context(BTreeSet::from([Capability::Observe])), + ); + + assert_eq!(decision, Decision::Allow); +} + +#[test] +fn mismatched_mcp_route_cannot_be_reinterpreted_as_another_action() { + let call = validated_call("originweave.observe"); + let decision = evaluate_mcp( + &call, + &request(ActionKind::Navigate), + &context(BTreeSet::from([Capability::Navigate])), + ); + + assert_eq!(decision, Decision::Deny(DenialReason::McpActionMismatch)); +} + +#[test] +fn matching_mcp_route_does_not_bypass_existing_policy_denials() { + let call = validated_call("originweave.navigate"); + let decision = evaluate_mcp( + &call, + &request(ActionKind::Navigate), + &context(BTreeSet::from([Capability::Observe])), + ); + + assert_eq!( + decision, + Decision::Deny(DenialReason::MissingCapability(Capability::Navigate)) + ); +} diff --git a/crates/originweave-resource/src/lib.rs b/crates/originweave-resource/src/lib.rs index 8c77aa3d0..35a30789a 100644 --- a/crates/originweave-resource/src/lib.rs +++ b/crates/originweave-resource/src/lib.rs @@ -9,6 +9,8 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +use std::fmt; + /// A validation error in a resource budget. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BudgetError { @@ -18,6 +20,19 @@ pub enum BudgetError { SoftExceedsHard, } +impl fmt::Display for BudgetError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ZeroLimit => formatter.write_str("resource budget limits must be nonzero"), + Self::SoftExceedsHard => { + formatter.write_str("resource budget soft limits must not exceed hard limits") + } + } + } +} + +impl std::error::Error for BudgetError {} + /// Validated resource limits for one agent task. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ResourceBudget { diff --git a/crates/originweave-resource/tests/error_contract.rs b/crates/originweave-resource/tests/error_contract.rs new file mode 100644 index 000000000..cc8b88dfb --- /dev/null +++ b/crates/originweave-resource/tests/error_contract.rs @@ -0,0 +1,21 @@ +use originweave_resource::BudgetError; +use std::error::Error as _; + +#[test] +fn budget_errors_expose_stable_standard_error_contract() { + let cases = [ + ( + BudgetError::ZeroLimit, + "resource budget limits must be nonzero", + ), + ( + BudgetError::SoftExceedsHard, + "resource budget soft limits must not exceed hard limits", + ), + ]; + + for (error, expected_message) in cases { + assert_eq!(error.to_string(), expected_message); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-tls/src/lib.rs b/crates/originweave-tls/src/lib.rs index f9ec5e877..9024946f4 100644 --- a/crates/originweave-tls/src/lib.rs +++ b/crates/originweave-tls/src/lib.rs @@ -14,6 +14,7 @@ mod evidence; mod handshake; mod identity; mod policy; +mod revocation; mod trust; mod validity; @@ -29,6 +30,7 @@ pub use policy::{ MAX_MINIMUM_LEAF_VALIDITY, MAX_SERVER_CERTIFICATE_BYTES, MAX_SERVER_CERTIFICATE_COUNT, MAX_TLS_HANDSHAKE_TIMEOUT, TlsClientPolicy, }; +pub use revocation::{RevocationMaterialFreshness, RevocationMaterialFreshnessError}; pub use trust::{ MAX_TRUST_ROOT_BYTES, MAX_TRUST_ROOT_COUNT, TrustBundleIdentifier, TrustRootBundle, }; diff --git a/crates/originweave-tls/src/revocation.rs b/crates/originweave-tls/src/revocation.rs new file mode 100644 index 000000000..e500125a2 --- /dev/null +++ b/crates/originweave-tls/src/revocation.rs @@ -0,0 +1,174 @@ +use std::fmt; + +/// A deterministic freshness window for independently verified revocation material. +/// +/// This value does not fetch, parse, authenticate, or interpret OCSP responses or +/// certificate revocation lists. A trusted adapter must first obtain and +/// cryptographically validate the revocation material, then pass the signed +/// `thisUpdate` and `nextUpdate` timestamps into this authority together with a +/// caller-selected local maximum freshness window. Passing this check proves only +/// that the supplied material is within both its signed interval and the caller's +/// bounded freshness policy; it does not prove that any certificate is unrevoked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RevocationMaterialFreshness { + this_update_unix_seconds: u64, + next_update_unix_seconds: u64, + maximum_window_seconds: u64, +} + +impl RevocationMaterialFreshness { + /// Create a non-empty, locally bounded freshness window from trusted signed timestamps. + /// + /// The signed window is half-open: `thisUpdate <= trusted_time < nextUpdate`. + /// Equal or reversed timestamps fail closed because they provide no usable + /// interval. `maximum_window_seconds` is a separate local policy ceiling and + /// must be nonzero; signed material whose declared interval exceeds that + /// ceiling is rejected even if its timestamps are otherwise well-formed. + pub const fn new( + this_update_unix_seconds: u64, + next_update_unix_seconds: u64, + maximum_window_seconds: u64, + ) -> Result { + if next_update_unix_seconds <= this_update_unix_seconds { + return Err(RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds, + next_update_unix_seconds, + }); + } + if maximum_window_seconds == 0 { + return Err(RevocationMaterialFreshnessError::ZeroMaximumWindow); + } + + let window_seconds = next_update_unix_seconds - this_update_unix_seconds; + if window_seconds > maximum_window_seconds { + return Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds, + maximum_window_seconds, + }); + } + + Ok(Self { + this_update_unix_seconds, + next_update_unix_seconds, + maximum_window_seconds, + }) + } + + /// Return the signed time at which the revocation material becomes current. + #[must_use] + pub const fn this_update_unix_seconds(self) -> u64 { + self.this_update_unix_seconds + } + + /// Return the signed time at which this freshness window stops being usable. + #[must_use] + pub const fn next_update_unix_seconds(self) -> u64 { + self.next_update_unix_seconds + } + + /// Return the caller-selected maximum accepted signed-window duration. + #[must_use] + pub const fn maximum_window_seconds(self) -> u64 { + self.maximum_window_seconds + } + + /// Evaluate one trusted time against the half-open freshness window. + /// + /// A time before `thisUpdate` is not yet usable. A time equal to or later + /// than `nextUpdate` is stale. Both cases fail closed without making any + /// statement about the certificate's revocation state. + pub const fn evaluate( + self, + trusted_time_unix_seconds: u64, + ) -> Result<(), RevocationMaterialFreshnessError> { + if trusted_time_unix_seconds < self.this_update_unix_seconds { + Err(RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds, + this_update_unix_seconds: self.this_update_unix_seconds, + }) + } else if trusted_time_unix_seconds >= self.next_update_unix_seconds { + Err(RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds, + next_update_unix_seconds: self.next_update_unix_seconds, + }) + } else { + Ok(()) + } + } +} + +/// A deterministic reason that verified revocation material is not fresh enough to use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RevocationMaterialFreshnessError { + /// The supplied signed timestamps do not define a non-empty freshness window. + InvalidWindow { + /// Signed `thisUpdate` timestamp in Unix seconds. + this_update_unix_seconds: u64, + /// Signed `nextUpdate` timestamp in Unix seconds. + next_update_unix_seconds: u64, + }, + /// The caller supplied no positive local maximum freshness duration. + ZeroMaximumWindow, + /// The material's signed interval exceeds the caller's local freshness ceiling. + WindowExceedsMaximum { + /// Duration of the signed `thisUpdate` to `nextUpdate` interval in seconds. + window_seconds: u64, + /// Caller-selected maximum accepted interval in seconds. + maximum_window_seconds: u64, + }, + /// Trusted time falls before the material's signed `thisUpdate` timestamp. + NotYetValid { + /// Trusted evaluation time in Unix seconds. + trusted_time_unix_seconds: u64, + /// Signed `thisUpdate` timestamp in Unix seconds. + this_update_unix_seconds: u64, + }, + /// Trusted time is equal to or later than the material's signed `nextUpdate` timestamp. + Expired { + /// Trusted evaluation time in Unix seconds. + trusted_time_unix_seconds: u64, + /// Signed `nextUpdate` timestamp in Unix seconds. + next_update_unix_seconds: u64, + }, +} + +impl fmt::Display for RevocationMaterialFreshnessError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidWindow { + this_update_unix_seconds, + next_update_unix_seconds, + } => write!( + formatter, + "revocation material window is invalid: thisUpdate {this_update_unix_seconds} must be before nextUpdate {next_update_unix_seconds}", + ), + Self::ZeroMaximumWindow => write!( + formatter, + "revocation material maximum freshness window must be greater than zero", + ), + Self::WindowExceedsMaximum { + window_seconds, + maximum_window_seconds, + } => write!( + formatter, + "revocation material window is {window_seconds} seconds, exceeding the local maximum of {maximum_window_seconds} seconds", + ), + Self::NotYetValid { + trusted_time_unix_seconds, + this_update_unix_seconds, + } => write!( + formatter, + "revocation material is not usable at trusted time {trusted_time_unix_seconds}; thisUpdate is {this_update_unix_seconds}", + ), + Self::Expired { + trusted_time_unix_seconds, + next_update_unix_seconds, + } => write!( + formatter, + "revocation material is stale at trusted time {trusted_time_unix_seconds}; nextUpdate is {next_update_unix_seconds}", + ), + } + } +} + +impl std::error::Error for RevocationMaterialFreshnessError {} diff --git a/crates/originweave-tls/src/trust.rs b/crates/originweave-tls/src/trust.rs index f3e3374b6..32aa66e17 100644 --- a/crates/originweave-tls/src/trust.rs +++ b/crates/originweave-tls/src/trust.rs @@ -19,6 +19,7 @@ impl TrustBundleIdentifier { pub fn parse(input: &str) -> Result { if input.is_empty() || input.len() > 128 + || !input.bytes().any(|byte| byte.is_ascii_alphanumeric()) || !input.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-') }) diff --git a/crates/originweave-tls/tests/policy_contract.rs b/crates/originweave-tls/tests/policy_contract.rs index 5a8b71ef4..4fad353b3 100644 --- a/crates/originweave-tls/tests/policy_contract.rs +++ b/crates/originweave-tls/tests/policy_contract.rs @@ -25,7 +25,7 @@ fn trust_bundle_identifier_is_bounded_and_ascii() { TrustBundleIdentifier::parse("enterprise_roots:v1").expect("valid trust bundle identifier"); assert_eq!(identifier.as_str(), "enterprise_roots:v1"); - for invalid in ["", "contains space", "한글", "slash/value"] { + for invalid in ["", "contains space", "한글", "slash/value", "---"] { assert!(matches!( TrustBundleIdentifier::parse(invalid), Err(TlsError::InvalidTrustBundleIdentifier) diff --git a/crates/originweave-tls/tests/revocation_freshness.rs b/crates/originweave-tls/tests/revocation_freshness.rs new file mode 100644 index 000000000..c7af7bd7c --- /dev/null +++ b/crates/originweave-tls/tests/revocation_freshness.rs @@ -0,0 +1,119 @@ +use std::error::Error as _; + +use originweave_tls::{RevocationMaterialFreshness, RevocationMaterialFreshnessError}; + +const MAXIMUM_WINDOW_SECONDS: u64 = 300; + +#[test] +fn revocation_material_freshness_uses_a_half_open_verified_window() { + let freshness = RevocationMaterialFreshness::new(1_000, 1_100, MAXIMUM_WINDOW_SECONDS); + assert!(freshness.is_ok()); + + if let Ok(freshness) = freshness { + assert_eq!(freshness.this_update_unix_seconds(), 1_000); + assert_eq!(freshness.next_update_unix_seconds(), 1_100); + assert_eq!(freshness.maximum_window_seconds(), MAXIMUM_WINDOW_SECONDS); + assert_eq!(freshness.evaluate(1_000), Ok(())); + assert_eq!(freshness.evaluate(1_099), Ok(())); + assert_eq!( + freshness.evaluate(999), + Err(RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds: 999, + this_update_unix_seconds: 1_000, + }) + ); + assert_eq!( + freshness.evaluate(1_100), + Err(RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds: 1_100, + next_update_unix_seconds: 1_100, + }) + ); + } +} + +#[test] +fn revocation_material_freshness_rejects_empty_or_reversed_windows() { + for (this_update, next_update) in [(1_000, 1_000), (1_001, 1_000)] { + assert_eq!( + RevocationMaterialFreshness::new(this_update, next_update, MAXIMUM_WINDOW_SECONDS), + Err(RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds: this_update, + next_update_unix_seconds: next_update, + }) + ); + } +} + +#[test] +fn revocation_material_freshness_requires_a_bounded_local_policy_window() { + assert_eq!( + RevocationMaterialFreshness::new(1_000, 1_100, 0), + Err(RevocationMaterialFreshnessError::ZeroMaximumWindow) + ); + + let exact_maximum = RevocationMaterialFreshness::new(1_000, 1_300, MAXIMUM_WINDOW_SECONDS); + assert!(exact_maximum.is_ok()); + + assert_eq!( + RevocationMaterialFreshness::new(1_000, 1_301, MAXIMUM_WINDOW_SECONDS), + Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: 301, + maximum_window_seconds: MAXIMUM_WINDOW_SECONDS, + }) + ); + + assert_eq!( + RevocationMaterialFreshness::new(1, u64::MAX, 1), + Err(RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: u64::MAX - 1, + maximum_window_seconds: 1, + }) + ); +} + +#[test] +fn revocation_freshness_errors_are_stable_and_source_free() { + let invalid = RevocationMaterialFreshnessError::InvalidWindow { + this_update_unix_seconds: 1_000, + next_update_unix_seconds: 1_000, + }; + let zero_maximum = RevocationMaterialFreshnessError::ZeroMaximumWindow; + let too_long = RevocationMaterialFreshnessError::WindowExceedsMaximum { + window_seconds: 301, + maximum_window_seconds: MAXIMUM_WINDOW_SECONDS, + }; + let future = RevocationMaterialFreshnessError::NotYetValid { + trusted_time_unix_seconds: 999, + this_update_unix_seconds: 1_000, + }; + let stale = RevocationMaterialFreshnessError::Expired { + trusted_time_unix_seconds: 1_100, + next_update_unix_seconds: 1_100, + }; + + assert_eq!( + invalid.to_string(), + "revocation material window is invalid: thisUpdate 1000 must be before nextUpdate 1000" + ); + assert_eq!( + zero_maximum.to_string(), + "revocation material maximum freshness window must be greater than zero" + ); + assert_eq!( + too_long.to_string(), + "revocation material window is 301 seconds, exceeding the local maximum of 300 seconds" + ); + assert_eq!( + future.to_string(), + "revocation material is not usable at trusted time 999; thisUpdate is 1000" + ); + assert_eq!( + stale.to_string(), + "revocation material is stale at trusted time 1100; nextUpdate is 1100" + ); + + for error in [invalid, zero_maximum, too_long, future, stale] { + assert!(error.source().is_none()); + } +} diff --git a/docs/adr/0016-bap-task-lifecycle-authority.md b/docs/adr/0016-bap-task-lifecycle-authority.md new file mode 100644 index 000000000..54fae8607 --- /dev/null +++ b/docs/adr/0016-bap-task-lifecycle-authority.md @@ -0,0 +1,123 @@ +# ADR 0016: BAP task lifecycle and state authority + +- **Status:** Proposed +- **Date:** 2026-08-22 +- **Supersedes:** None +- **Superseded by:** None + +## Context + +OriginWeave needs a deterministic lifecycle primitive for governed browser-agent work before durable BAP transport, persistence, idempotency, or crash recovery can be added safely. A task state is security-relevant because downstream components may use it to decide whether work may start, resume, complete, reconcile, or terminate. If adapters, persistence layers, browser drivers, or recovery code can mint state independently, OriginWeave would inherit ambient execution authority from whichever boundary supplied the most convenient state value. + +The `originweave-bap` crate therefore introduces a typed in-memory state machine with monotonic transition receipts and fail-closed recovery validation. The crate deliberately owns no browser, network, model, secret, approval, persistence, tenant-authentication, or protocol authority. External protocols may project lifecycle intent into this kernel, but protocol metadata cannot bypass its transition rules or upgrade a task's authority. + +## Decision drivers + +- Keep task-state authority explicit and deterministic rather than distributed across protocol adapters. +- Prevent stale, unreachable, or terminal lifecycle snapshots from reopening governed work. +- Preserve a monotonic transition sequence suitable for later durable replay evidence without claiming persistence today. +- Separate lifecycle state from browser, network, secret, model, approval, and tenant authority. +- Make waiting, checkpoint, reconciliation, completion, cancellation, expiry, and dead-letter behavior typed and testable. +- Keep recovery validation fail closed when a supplied state/sequence pair cannot arise from the reviewed state machine. + +## Assumptions and authority boundaries + +- The lifecycle is an in-memory logical primitive; it is not a durable task repository. +- Creating or restoring a lifecycle does not authenticate a caller, tenant, browser session, document, origin, destination, secret, model, approval, or external side effect. +- A transition receipt proves only what this in-memory lifecycle instance accepted. It is not durable audit evidence until a separate authenticated persistence boundary stores it. +- Waiting for approval is a lifecycle condition, not proof that approval exists. A later approval authority must independently authenticate and authorize any decision before resumption. +- `Succeeded` is entered only after a caller asserts that its separately governed post-condition has been verified; the lifecycle does not itself verify that post-condition. +- Reconciliation and dead-letter states preserve control-flow intent only. Durable reconciliation evidence remains the responsibility of a later persistence/recovery boundary. + +## Options considered + +### Let each BAP or MCP adapter own its own state machine + +Rejected. Adapter-local state machines would duplicate policy, make recovery semantics drift by protocol, and allow external protocol metadata to become implicit OriginWeave execution authority. + +### Store task state as an unrestricted string or integer + +Rejected. Untyped state admits unknown values, weakens exhaustive transition review, and makes invalid or stale recovery snapshots difficult to reject deterministically. + +### Allow restored state to resume whenever the state name looks resumable + +Rejected. State-only recovery loses monotonic history. A state/sequence pair that cannot be reached through the reviewed transitions must fail closed rather than becoming execution authority. + +### Centralize logical lifecycle transitions in a typed Rust kernel + +Selected. + +## Decision + +If Accepted, OriginWeave applies these lifecycle rules: + +1. **One typed kernel owns logical BAP task state.** `originweave-bap` is the canonical state-transition authority for the task lifecycle represented by this contract. Protocol adapters may request transitions but do not mint lifecycle state directly. +2. **Transitions are explicit and fail closed.** The kernel accepts only reviewed event/state combinations. Invalid events preserve the existing state and sequence and return a typed error. +3. **Terminal states never reopen.** `Succeeded`, `Failed`, `Cancelled`, `Expired`, and `DeadLettered` reject later lifecycle events. +4. **Waiting and checkpoint states require explicit resumption.** Approval wait, external-input wait, and checkpoint states do not silently become running work. +5. **Reconciliation is distinct from normal suspension.** A task in `ReconciliationRequired` cannot use the ordinary resume path; it requires explicit reconciliation resolution or governed dead-letter handling. +6. **Transition sequence is monotonic and bounded.** Every accepted transition advances the sequence exactly once. Sequence exhaustion fails closed instead of wrapping. +7. **Recovery validates reachability.** A supplied state/sequence snapshot must be reachable under the same reviewed state machine. Unreachable snapshots are rejected with a typed restore error. +8. **Lifecycle state grants no ambient authority.** A `Running`, resumable, or otherwise valid lifecycle state does not authorize browser I/O, network destinations, secret resolution, model access, approvals, external protocol operations, or tenant access. Those authorities must be revalidated by their owning boundaries. +9. **Durability is a separate owner.** This contract does not claim atomic persistence, idempotency, locking, authenticated replay evidence, side-effect reconciliation, or crash-safe recovery. Later durable components must bind those concerns to lifecycle receipts without weakening this state authority. +10. **External protocol state is projected, not inherited.** BAP, MCP, WebDriver BiDi, CDP, or other adapters may translate reviewed external events into typed lifecycle requests only after their own authentication and policy checks. External state labels cannot overwrite the kernel directly. + +## Consequences + +OriginWeave gains one reviewable state authority that later transport, idempotency, persistence, and recovery slices can compose without duplicating transition semantics. Invalid transitions and unreachable recovery snapshots have deterministic typed failures, while terminal and reconciliation states have explicit closure behavior. + +The trade-off is that adapters and durable stores must perform explicit mapping and validation instead of assigning state directly. The current slice also cannot claim commercial crash recovery until durable authenticated evidence and side-effect reconciliation are implemented separately. + +## Failure and degraded behavior + +- An invalid event returns a typed transition error and leaves state/history unchanged. +- A terminal lifecycle rejects all later events rather than reopening work. +- Sequence exhaustion returns a typed failure rather than wrapping or silently reusing an identifier. +- An unreachable restored state/sequence pair is rejected rather than normalized into a nearby valid state. +- Missing browser, tenant, policy, destination, secret, approval, persistence, or recovery authority is not converted into lifecycle success. +- If a future adapter cannot map external protocol state without ambiguity, it must fail closed or require reconciliation rather than inventing a lifecycle transition. + +## Security / privacy / governance impact + +This decision narrows authority. It prevents external protocol metadata, stale snapshots, or arbitrary state assignment from becoming execution authority and keeps lifecycle state separate from sensitive-data, secret, browser, network, model, approval, and tenant boundaries. The lifecycle stores no secret values or personal-data payloads by itself. Any future persistent representation must independently satisfy OriginWeave data-governance, retention, tenant-isolation, integrity, and evidence requirements. + +## Tests and acceptance evidence + +The owning branch must keep executable evidence for: + +- the reviewed created/admitted/running/waiting/checkpointed/reconciliation/terminal transition paths; +- fail-closed invalid transitions with no sequence advancement; +- terminal irreversibility; +- cancellation and expiry across allowed pre-dispatch and suspended states; +- explicit reconciliation resolution and governed dead-letter behavior; +- monotonic transition receipts and sequence-exhaustion failure; +- recovery acceptance for reachable snapshots and rejection for unreachable snapshots; and +- deterministic public Rust error contracts. + +Repository contracts must also require this ADR so the `originweave-bap` control-plane boundary cannot remain undocumented while the crate is present. Exact protected-main acceptance still depends on current-head CI, exact owned-production coverage, rustdoc, security evidence, review, live governance, and integration state; ADR presence does not substitute for those gates. + +## Migration and rollback + +No database migration is introduced. Existing callers on this branch construct the typed lifecycle directly. A future durable task repository should persist state and transition evidence in an authenticated form that can be validated by this kernel rather than introducing a second transition authority. + +Rollback before acceptance is removal of the active BAP lifecycle branch and its Proposed ADR. After acceptance, rollback or replacement must preserve fail-closed terminal/recovery semantics or explicitly supersede this ADR with a reviewed migration for any persisted lifecycle representation. + +## Open follow-ups + +- Bind durable idempotency receipts to exact accepted transitions without making retry metadata task authority. +- Define authenticated persistence, atomicity, and concurrency semantics for lifecycle plus command evidence. +- Define crash-recovery classification and reconciliation for ambiguous external side effects. +- Map authenticated BAP/MCP transport messages into typed lifecycle requests without ambient protocol authority. +- Propagate cancellation and expiry into real browser/process supervision only after the corresponding runtime authority exists. + +## Supersession / reversal conditions + +Supersede this ADR if OriginWeave replaces the BAP lifecycle model, introduces a materially different durable event-sourced task authority, or moves canonical task-state ownership to another reviewed component. A successor must preserve explicit state authority, terminal fail-closure, monotonic recovery evidence, and the rule that lifecycle state cannot mint unrelated browser/network/secret/model/approval/tenant authority. + +## References + +ContextualWisdomLab. (2026). *OriginWeave architecture* [Repository specification]. *OriginWeave*. [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) + +ContextualWisdomLab. (2026). *OriginWeave architecture decision records* [Repository specification]. *OriginWeave*. [`README.md`](README.md) + +ContextualWisdomLab. (2026). *Agent development contract* [Repository specification]. *OriginWeave*. [`../../AGENTS.md`](../../AGENTS.md) diff --git a/docs/doctoring/rust-toolchain-freshness.md b/docs/doctoring/rust-toolchain-freshness.md new file mode 100644 index 000000000..a00e7fb08 --- /dev/null +++ b/docs/doctoring/rust-toolchain-freshness.md @@ -0,0 +1,44 @@ +# Rust toolchain freshness and reproducibility + +## Decision + +OriginWeave keeps Rust `1.97.1` as the exact stable compiler baseline. As of +2026-08-19 this is the current stable point release, so the generic compiler +suggestion to upgrade does not justify replacing it with a floating `stable` +channel. + +Production line, region, and function coverage remains on the stable compiler. +Branch coverage uses the independently date-pinned `nightly-2026-08-18` +toolchain because upstream `cargo-llvm-cov` still identifies Rust branch +coverage as unstable and nightly-only. Every branch-coverage command must use +the same date pin, and exact-head CI must prove that `llvm-tools-preview`, the +pinned `cargo-llvm-cov` release, the workspace, and the coverage verifier remain +compatible before merge. + +The root `rust-toolchain.toml` is tracked through GitHub Dependabot's +`rust-toolchain` ecosystem. Toolchain changes therefore arrive as reviewable +pull requests rather than silently changing underneath local or CI builds. +Date-pinned branch-coverage nightly updates remain explicit infrastructure +changes and must preserve the repository contract test. + +## Failure interpretation + +The historical OriginWeave coverage failure at PR #192 predecessor head +`ccb7d31dfe7654bab800d463c2391cc1a19c7d74` was not proof that the compiler was +too old. The compiler emitted the generic note while rejecting a non-stable +const conversion in test code. The current PR #192 head moved that conversion +out of a constant and passed the complete native CI workflow. Toolchain +freshness and source compatibility are therefore maintained as separate +controls. + +## References + +GitHub. (2025, August 19). *Dependabot now supports Rust toolchain updates*. +GitHub Changelog. +https://github.blog/changelog/2025-08-19-dependabot-now-supports-rust-toolchain-updates/ + +Rust Project Developers. (2026, July 16). *Announcing Rust 1.97.1*. Rust Blog. +https://blog.rust-lang.org/2026/07/16/Rust-1.97.1/ + +Taiki Endo and contributors. (2026). *cargo-llvm-cov* (Version 0.8.6) +[Computer software]. GitHub. https://github.com/taiki-e/cargo-llvm-cov diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 000000000..8a702c75f --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,346 @@ +# Product and Technical Gap Baseline + +This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, architecture decisions, or live GitHub state. It keeps buyer-visible gaps, current issues, active pull-request evidence, and commercial completion tracks in one discoverable place. Protected `main` is the implementation boundary: code in an open pull request is not shipped behavior. + +## Observed snapshot: 2026-08-26 + +### Protected-main truth + +- Protected `main` is at `b05d5acca82b9d916ada2c8e82f59f92a89817e1` for this snapshot. Since the 2026-08-24 observation (`0841d2ab`), protected `main` absorbed #196 (dated gap baseline publication), #216 (RFC 3986 evidence-path syntax enforcement), #194 (branch-coverage nightly and toolchain tracking refresh), #168 (typed MCP stateless tool-routing foundations), and #151 (exact crash-root termination before crash credit). +- Phase 0 remains complete as a reusable safety-kernel foundation: typed policy contracts, destination classification, direct TCP peer verification, TLS service identity, evidence bounds, resource mitigation, document-node authority, and protected-main tests. +- Phase 1 is **in progress**, not shipped. The first real Chromium vertical slice still needs the active WebDriver BiDi transport stack to reach protected `main`, then compose isolated Chromium launch, session/context identity, semantic observation, typed action authorization, native browser input, post-condition proof, evidence, cancellation, crash recovery, and profile/process teardown. +- HTTP/1.1 bounds, downloads/MIME, proxy/PAC consumption, full browser-network integration, the sensitive-data broker runtime, durable WARC/PROV capture, persistent task/API surfaces, signed cross-platform distribution, enterprise administration, and release-grade buyer acceptance remain open. +- Active pull requests remain evidence, not shipped behavior. Successful checks on a feature or stacked branch do not prove that protected `main` contains the capability or that a child can merge before its prerequisite. + +### Open pull requests + +The live repository contained **126 open pull requests: 54 non-draft and 72 draft** when this snapshot re-paginated the complete open inventory. Compared with the prior **2026-08-24 158-PR snapshot**, the current inventory is 32 PRs smaller. Intervening queue consolidation includes #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 being merged into their immediate stacked prerequisites, while PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review. Those transitions are queue consolidation, not protected-main delivery; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`, with 13 open issues and no releases or tags. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. + +#### 2026-08-26 maintenance-loop record + +The interactive maintenance loop performed the following verified state changes on exact heads; none of them is protected-main behavior until merged: + +| Action | Exact evidence | +|---|---| +| Supersession closure | #153 closed with replacement evidence: base-stack tip (`4da223ac`) already implements `_terminate_owned_process_bounded` exit-race tolerance that supersedes the branch delta | +| Conflict reconciliation | Merge commits pushed to #37 (`27f6acd6`, ci.yml aligned to reviewed `nightly-2026-08-18` pin), #149 (`7852a540` + rustfmt fix `54f96008`), #152 (`65b0c705`), #173 (`ecc9574a`), #175 (`765c88f6`, keeps `crate_root.rs` naming) | +| Governance remediation (#212) | #43 reconciled with main in `04e262d5`; the `chrome_sandbox` workflow mutation was first removed, then restored under recorded independent authorization (issue #212 option (b)) because the PR's own contract test fails closed without it; fresh exact-head checks re-ran on the restored head | +| Security finding fix (#124) | Strix vuln-0001 (Unicode homoglyph path confusion, MEDIUM) remediated in `30cc458b`: audited workflow paths now restricted to a canonical ASCII alphabet with homoglyph/fraction-slash/fullwidth regression contract tests; CHANGELOG updated | +| Fail-closed provider re-dispatch | ~21 failed Strix required-check runs re-dispatched on unchanged exact heads; completed reruns returned success on #46, #48, #156, #157, #159, #218, and #219 heads at snapshot time; cancellations only where newer heads superseded the run | +| Current-head review re-dispatch | Central merge-scheduler dispatches sent for #47, #62, #63, #65, #74, #166, #173, #175, and #220 because their stale `CHANGES_REQUESTED` verdicts cited coverage-evidence results that are green on the same heads today | + +#### Organization review-pipeline congestion record + +Between 2026-08-26T02:44Z and 2026-08-26T03:35Z the organization-wide Actions queue exhibited a systemic backlog: scheduler, OpenCode-review-dispatch, Noema, and Strix runs across `.github`, `naruon`, `pg-erd-cloud`, and OriginWeave sat `queued`/`pending` while only single-digit runs were `in_progress`. This delays every current-head AI review and therefore every ruleset-gated merge. It is an infrastructure-capacity signal, not a code defect, and it does not authorize merging without current-head review evidence. + +Representative active workstreams at this snapshot were: + +| Workstream | Representative active PR evidence | Delivery boundary | +|---|---|---| +| Product baseline | (merged: #196 on 2026-08-24) | Baseline publication reached protected `main`; this document is its successor snapshot | +| Presentation identity | #229 at `585a7d5545b13f18d76f79100ff4d47ac423e861` onto `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | Ready/non-draft local privacy kernel; all observed exact-head checks except Strix passed, but the PR remains blocked and review-required, and no Chromium adapter or protected-main shipment is claimed | +| Enterprise approval authority | #220 | Ready/non-draft bounded maker-checker approval lifecycle on the exact `ApprovalScope`; all current-head checks green at snapshot, awaiting current-head review evidence | +| Release artifact identity | #218 and #219 | Ready/non-draft fail-closed benchmark release decision and canonical release manifest binding; Strix provider-failure reruns completed green on both heads | +| Schema-bound extraction and BAP lifecycle | #209 and #208 | Ready/non-draft schema-bound extraction contract and resumable task-lifecycle kernel; #209 Strix rerun green, #208 rerun re-dispatched after a further provider failure | +| WebDriver BiDi transport | #188 through #205 | Active stack whose top #205 merged into its prerequisite branch, not protected `main`; it exercises framed `locateNodes` exchange over a bounded WebSocket opening path, but authenticated browser-process provenance, semantic task execution, and protected-main shipment remain unproven | +| MCP adapter | (#168 merged) and #170 | Typed MCP routing foundations are protected-main behavior since 2026-08-24; conservative `tools/list` cache metadata remains active-PR evidence with a Strix rerun in flight | +| Workflow-registry audit | #124 | Real Strix finding vuln-0001 (Unicode homoglyph path confusion, MEDIUM) remediated on head `30cc458b` with regression contract tests; fresh exact-head checks and review re-running | +| Controlled Chromium and recovery | #65, #70-#73, #100, #105, #142-#152 and descendants | Real pinned-browser fixture, semantic location, resource, crash, and teardown evidence exists on active stacks; evidence does not transfer across heads or prerequisites | +| Durable WARC/PROV evidence | #210, #217 | Bounded WARC resource records and PROV JSON-LD binding are draft active-PR foundations; durable ownership, replay, retention/deletion, and browser side-effect reconciliation remain open | +| Manifest V3 and native messaging | #27, #43 governance remediation, and the extension/native-host stack including #154 and #169 | Compatibility and Agent-authority isolation remain incomplete until exact release artifacts and platform matrices are proven; #43's sandbox workflow mutation is now owner-authorized under issue #212 option (b) | +| Sensitive-data and model route policy | #10 and its active policy stacks | Deterministic policy values exist, but trusted broker execution, retention/deletion, runtime isolation, and auditable product workflows remain open | +| VPN/profile intent | #149 | Bounded WireGuard/IKEv2 profile authority reconciled with main (`54f96008`); it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof | + +PR #205 head `f427aa69151987d7e3369bd96d5739ea38d0f7ad` merged as `6c5ef5e2079d54c617183ecfa757e406f48f0aea` into stacked prerequisite branch `feat/webdriver-bidi-websocket-frame-transport` at base `c1bc7e78f3a9debf4f517fb6b5f11dd67be4ad92`. Its successful exact-head checks are stacked-branch integration evidence only; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`. + +#### Current exact-head active PR evidence + +The following newest slices were re-fetched from GitHub for this snapshot. Their exact base/head pairs are recorded so later checks, reviews, and restacks cannot be confused with predecessor evidence: + +| PR | State | Exact base head | Exact head | +|---|---|---|---| +| #220 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e0740a6f3a41067a4460249378e0266815018a74` | +| #219 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `3e34a54ae279686a28309d59b8b3b9bfbd283a80` | +| #218 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `911ea33d8a5aca7673307bb6fdcad4b450f5c111` | +| #209 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `b35d739017aa5d361b605be48045be50b5a35f6f` | +| #208 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e41d3be4c290c4e434aac33d777e511dfb94e03d` | +| #124 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `296ad25bb541023dbc869ae07ae1d853820f83a4` | + +These rows are delivery evidence only. None has counted independent approval in the current collaborator inventory, and predecessor rows from earlier snapshots are retained below as regression anchors that must never be promoted to current-head evidence. + +#### Regression-anchor exact-head evidence: superseded 2026-08-24 rows + +The following rows were current on 2026-08-24 and are retained only as regression anchors; every listed head has since been superseded or merged and must never be promoted to current-head evidence: + +| PR | State | Exact base head | Exact head | +|---|---|---|---| +| #222 | Draft | `56fcfa56525e4f2e980e0ee05b6776d621bcddc5` | `1e2ce3d4071a1a75ee891bdcd71c506b3b50d4bc` | +| #221 | Draft | `8145d40f1b028a8f4dc7e7da47ac89bb9e5bb2c7` | `6f339df1e5b3ddb265f4ddd7b262d4de1e0b5e1f` | +| #220 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `ed4cab16cf88c76ce1c145a22d0a274ef2d57263` | +| #219 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `8145d40f1b028a8f4dc7e7da47ac89bb9e5bb2c7` | +| #218 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `49e98fba6974219b3bb0336c822b12667f1e1c03` | +| #217 | Draft | `529d11a3571f6b1834b9baa49ef67eb08f043978` | `56fcfa56525e4f2e980e0ee05b6776d621bcddc5` | +| #216 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `75130851a0f7ce528a7a36382eb026ac7942a0aa` | +| #214 | Draft | `40d642d5470a7753b8211907c190367f742f2f12` | `f79999681866ecf0e5fe17d895170f3f6cae7361` | +| #211 | Draft | `85cc477688246900697f4cfb91c0c8f1f692934a` | `40d642d5470a7753b8211907c190367f742f2f12` | +| #210 | Draft | `c38b9665774d6b3754e572bed527737b5e179833` | `529d11a3571f6b1834b9baa49ef67eb08f043978` | +| #209 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `c38b9665774d6b3754e572bed527737b5e179833` | +| #208 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `85cc477688246900697f4cfb91c0c8f1f692934a` | + +The stack topology shows #209 → #210 → #217 → #222 (WARC/PROV chain), #208 → #211 → #214 (BAP chain), #218 → #221 → #220 (release/enterprise chain) at this snapshot. Every row above remains active-PR evidence; none is protected-main behavior. + +### Required-check provider failure record + +On 2026-08-23 the required Strix security scan failed closed on exact heads of #220 (`ed4cab16…`), #218 (`49e98fba…`), and #208 (`85cc4776…`) because its LLM provider/backend was unavailable (rate limit, token cap, connection, warm-up, or model-behavior failure); no vulnerability report artifact was produced, so the workflow correctly refused to convert an incomplete scan into passing security evidence. Failed jobs were re-dispatched on the unchanged exact heads on 2026-08-24 and again on 2026-08-26. This is a provider-infrastructure failure record, not a weakening of the fail-closed gate or a substitute for a completed authoritative scan. + +On 2026-08-26 rerun outcomes were verified per run: completed reruns returned `success` on the heads of #46, #48, #156, #157, #159, #218, and #219; several earlier runs for #37, #43, and #149 were cancelled only because conflict-reconciliation pushes created newer heads with fresh scans; remaining reruns were still in flight at snapshot time. One rerun (#124) produced a real MEDIUM finding (vuln-0001) instead of provider noise; that finding was remediated on the branch head rather than suppressed, preserving the fail-closed contract. + +#### #195/#198 WebDriver BiDi opening path status + +Phase 1 is **in progress**, not shipped. #195 and #198 provide bounded WebSocket opening-path evidence on active branches; framed BiDi commands, authenticated browser-process provenance, semantic task execution, and protected-main integration remain open. + +#### #149 VPN/profile intent status + +PR #149 is a ready (non-draft) pull request whose conflict reconciliation and rustfmt correction landed on head `54f96008` on 2026-08-26; it still only describes bounded WireGuard/IKEv2 profile authority and does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof. + +The current queue must be processed in dependency order. A green child branch cannot substitute for current checks and review on its prerequisite, synthetic merge, or eventual protected-main commit. PRs that only duplicate, supersede, or preserve stale branch topology should be closed with explicit replacement evidence rather than retained indefinitely; this loop exercised that policy by closing superseded #153 with replacement evidence. + +### Review and merge authority + +The active `CWL Central required workflows` ruleset (re-fetched for this snapshot) requires one approving review, resolved review threads, no last-push approval requirement, `merge`/`squash` merge methods, and seven configured required workflows (`close-empty-pr`, `opencode-review`, `pr-review-merge-scheduler`, `security-scan`, `strix`, `sast-semgrep`, `noema-review`). The current collaborator inventory contains only `seonghobae` with administration and push permissions, creating a **reviewer-provisioning gap** for counted non-author approval. + +This gap does not authorize self-approval, stale-head merges, administrative bypass, or weaker checks. Because the current GitHub ruleset independently requires a counted approval, the solo-maintainer hold does not satisfy the live merge gate: an eligible non-author collaborator must submit a formal `APPROVED` review on the current head. Until that reviewer-provisioning gap is repaired, protected-main merges stop even when exact-head checks, security gates, complete coverage, rustdoc/Clippy, threads, and AI-review evidence are otherwise complete. Before any merge decision, re-fetch the exact ruleset, collaborators, PR head/base, reviews, unresolved threads, and required checks; do not assume this dated observation remains current. + +### Open issues and operational signals + +| Issue | Current gap or signal | +|---|---| +| #28 | First real Chromium Agent Task vertical slice; highest immediate Phase 1 buyer-visible gap | +| #27 | Complete Manifest V3 compatibility and extension-authority isolation matrix | +| #9 | Bounded HTTP/1.1 semantics over the authenticated TLS stream | +| #10 | Purpose-bound operational PII disclosure and trusted broker/storage lifecycle | +| #123 | Fleet incident: disable orphaned TLS, HTTP, and one-shot workflow identities | +| #187 | Manual-authority review of the coverage-diagnostics workflow delta | +| #212 | Governance: remove or independently authorize the PR #43 MV3 workflow mutation — **option (b) executed 2026-08-26** with owner-directed authorization recorded on the issue and the mutation restored on the reconciled branch; re-evaluate if the authorization record is contested | +| #215 | Governance: restore an enforceable protected-main policy that does not create a routine admin bypass | +| #199 | Schema-bound extraction with durable WARC/PROV replay, retention, deletion, and offline verification | +| #200 | Stable BAP/MCP runtime API with authenticated, idempotent, cancellable, resumable task lifecycle | +| #201 | Signed cross-platform Chromium distribution, installer/updater, patch SLA, rollback, SBOM, and provenance | +| #202 | Enterprise control and experience plane: operator UI, Keyverse-compatible identity, tenancy, approval, audit, SLO, Figma, and Storybook | +| #203 | Release-grade web-agent benchmark and commercial acceptance gate bound to exact signed artifacts | + +Issue #206 (harden-runner custom detection initialization failure) was closed after its remediation landed on protected `main` between snapshots. + +The five newly separated product-completion tracks are **durable WARC/PROV replay**, **stable BAP/MCP runtime API**, **signed cross-platform Chromium distribution**, **enterprise control and experience plane**, and the **commercial acceptance gate**. They are separate issues because each has a distinct authority, data, release, and buyer-acceptance boundary. + +The hourly product-development loop is operational infrastructure, not proof that a browser product, issue, pull request, or release meets buyer acceptance. + +## Buyer-visible and technical gap matrix + +| Priority | Buyer-visible outcome | Protected-main status | Completion issue and acceptance evidence | +|---|---|---|---| +| P0 | A bounded task observes a real Chromium page, performs one typed action, verifies the post-condition, and emits provenance | **Open / Phase 1** | #28; repeated real Chromium E2E with isolated context, exact session/node authority, typed dispatch, post-condition, crash cleanup, and protected-main checks | +| P0 | Navigation consumes approved origin, resolution, route, TCP peer, TLS identity, bounded HTTP, redirect, MIME, and download policy | **Partial foundation** | #9 plus #28; real browser-network adapter proves the governed path is consumed end to end | +| P1 | Existing Chromium extensions remain compatible while Agent authority stays separate | **Partial active-PR evidence** | #27; exact supported-build/platform compatibility matrix, managed allow-list, native-host isolation, repeatability, and release binding | +| P1 | Authorized work can use necessary PII without ambient exposure | **Policy foundation; runtime open** | #10; opaque broker, exact field/purpose/destination/model policy, atomic use/revocation, retention/deletion, and value-free telemetry | +| P1 | Every released structured field is traceable to replayable source evidence | **Foundations only** | #199; durable WARC/PROV replay, integrity, retention, deletion, offline verification, extraction precision/recall, and 100% provenance completeness | +| P1 | External Agents integrate through a stable, authenticated product contract | **Partial active-PR MCP primitives** | #200; BAP 1.0, MCP 2026-07-28 adapter, idempotency, task cancellation/resume, checkpoint/reconciliation, and SDK conformance | +| P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | +| P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | +| P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | +| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 126-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | + +## Commercial completion definition + +OriginWeave is not complete merely because every low-level primitive exists in some open branch. A release candidate is commercially complete only when all of the following are true for the declared support profile: + +1. #9, #10, #27, and #28 are integrated on protected `main` as a complete browser/network/action/evidence chain. +2. #199 provides replayable, retention-governed evidence for every released structured result. +3. #200 exposes a stable authenticated runtime API and task lifecycle without raw Chromium authority leakage. +4. #201 produces signed, updateable, rollback-capable release artifacts bound to Chromium, SBOM, and provenance. +5. #202 supplies tenant-safe enterprise administration, approvals, audit, SLOs, incident recovery, accessible Figma/Storybook-backed UX, and control evidence. +6. #203 accepts the exact signed artifacts through a reproducible benchmark; missing or inconclusive evidence cannot be promoted to success. +7. Production function, line, region, and branch coverage and public API documentation remain exactly complete for OriginWeave-owned code. +8. CHANGELOG, version, supported-platform matrix, security policy, runbooks, licensing, release notes, upgrade/rollback guidance, and procurement evidence match the exact release. +9. No required check, browser/platform lane, security case, benchmark case, or independent review is skipped, stale, inherited, or represented by status-only evidence. +10. The open PR queue is reduced to bounded active work rather than being the only place where the product exists. + +## Next executable queue + +1. Drain the merge gate in dependency order: for every ready root PR whose current head is check-green with resolved threads, obtain the current ruleset's counted `APPROVED` review from an eligible non-author collaborator; OpenCode approval or skip evidence does not substitute for that GitHub review. If no eligible approver exists, record the reviewer-provisioning gap and do not merge. Root candidates include #37, #40, #43, #45–#48, #51, #62–#65, #74, #82, #124, #149, #152, #156–#166, #170, #173, #175, #208, #209, #218, and #219 as their re-dispatched checks land. Treat dependent children separately: only after a predecessor reaches protected `main`, retarget and independently revalidate its immediate child; preserve orders such as #218 → #221 → #220 rather than treating #208–#220 as a flat merge range. +2. Keep the organization review pipeline healthy: monitor the central Actions backlog recorded above; if OpenCode reviews stop landing on OriginWeave heads while the queue is idle, repair `ContextualWisdomLab/.github` dispatch/concurrency configuration rather than weakening any gate. +3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #181–#205 WebSocket opening path and framed BiDi command/response stack, then semantic observation, policy, action, post-condition, and recovery boundaries on protected `main`. +4. Finish #27 and #10 as separate security tracks; neither should be hidden inside the first browser PR. +5. Implement #199, then #200, so durable evidence and stable task authority precede broad enterprise integrations. +6. Implement #201 before making release/support claims; exact CI browser evidence must be bound to the actual signed artifact. +7. Design #202 in Figma, record the Figma File ID in the ADR, implement reusable design tokens and Storybook components, then add identity/tenant/approval/audit/operations integration. +8. Make #203 the final release gate across the exact signed distribution, not a source branch or model narrative. +9. Only after the commercial acceptance gate passes, increment the version, finalize CHANGELOG/release notes, publish signed artifacts, and verify upgrade/rollback from the prior supported release. + +## Evidence commands + +The volatile counts above are reproducible by paginating the complete open-PR inventory, flattening every page, and then inspecting each PR's exact head, checks, reviews, and review threads: + +```bash +set -euo pipefail +EVIDENCE_DIR="$(mktemp -d /tmp/originweave-evidence.XXXXXX)" +printf 'Evidence directory: %s\n' "$EVIDENCE_DIR" >&2 + +gh api --paginate --slurp 'repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100' \ + > "$EVIDENCE_DIR/open-pr-pages.json" +jq '[.[][]]' "$EVIDENCE_DIR/open-pr-pages.json" \ + > "$EVIDENCE_DIR/open-prs.json" +jq '{ + open_pull_requests: length, + non_draft: (map(select(.draft == false)) | length), + draft: (map(select(.draft == true)) | length) +}' "$EVIDENCE_DIR/open-prs.json" + +gh api 'repos/ContextualWisdomLab/OriginWeave/branches/main' \ + > "$EVIDENCE_DIR/main-branch.json" +gh api --paginate --slurp \ + 'repos/ContextualWisdomLab/OriginWeave/rules/branches/main?per_page=100' \ + > "$EVIDENCE_DIR/main-branch-rule-pages.json" +jq '[.[][]]' "$EVIDENCE_DIR/main-branch-rule-pages.json" \ + > "$EVIDENCE_DIR/main-branch-rules.json" +gh api --paginate --slurp \ + 'repos/ContextualWisdomLab/OriginWeave/collaborators?affiliation=all&per_page=100' \ + > "$EVIDENCE_DIR/collaborator-pages.json" +jq '[.[][]]' "$EVIDENCE_DIR/collaborator-pages.json" \ + > "$EVIDENCE_DIR/collaborators.json" + +jq -r '.[].number' "$EVIDENCE_DIR/open-prs.json" | while read -r PR; do + STABLE_HEAD=false + for ATTEMPT in 1 2 3; do + VERDICT_PATH="$EVIDENCE_DIR/pr-${PR}-merge-verdict.json" + VERDICT_TMP="$EVIDENCE_DIR/pr-${PR}-merge-verdict.json.tmp" + rm -f "$VERDICT_PATH" "$VERDICT_TMP" "$EVIDENCE_DIR/pr-${PR}-rechecked.json" + PR_JSON="$EVIDENCE_DIR/pr-${PR}.json" + gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" > "$PR_JSON" + HEAD_SHA=$(jq -r '.head.sha' "$PR_JSON") + BASE_SHA=$(jq -r '.base.sha' "$PR_JSON") + + gh api --paginate --slurp \ + "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100" \ + > "$EVIDENCE_DIR/pr-${PR}-check-runs.json" + gh api --paginate --slurp \ + "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/statuses?per_page=100" \ + > "$EVIDENCE_DIR/pr-${PR}-statuses.json" + gh api --paginate --slurp \ + "repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100" \ + > "$EVIDENCE_DIR/pr-${PR}-reviews.json" + gh api --paginate --slurp \ + "repos/ContextualWisdomLab/OriginWeave/actions/runs?head_sha=$HEAD_SHA&per_page=100" \ + > "$EVIDENCE_DIR/pr-${PR}-workflow-runs.json" + gh api graphql --paginate --slurp \ + -F owner=ContextualWisdomLab \ + -F name=OriginWeave \ + -F number="$PR" \ + -f query=' +query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $endCursor) { + nodes { id isResolved isOutdated } + pageInfo { hasNextPage endCursor } + } + } + } +}' > "$EVIDENCE_DIR/pr-${PR}-review-threads.json" + + jq -n \ + --arg head "$HEAD_SHA" \ + --slurpfile pr "$PR_JSON" \ + --slurpfile checks "$EVIDENCE_DIR/pr-${PR}-check-runs.json" \ + --slurpfile statuses "$EVIDENCE_DIR/pr-${PR}-statuses.json" \ + --slurpfile reviews "$EVIDENCE_DIR/pr-${PR}-reviews.json" \ + --slurpfile workflow_runs "$EVIDENCE_DIR/pr-${PR}-workflow-runs.json" \ + --slurpfile rules "$EVIDENCE_DIR/main-branch-rules.json" \ + --slurpfile collaborators "$EVIDENCE_DIR/collaborators.json" \ + --slurpfile threads "$EVIDENCE_DIR/pr-${PR}-review-threads.json" \ + --arg base "$BASE_SHA" \ + '( + [ + $rules[][]? + | select(.type == "pull_request") + | .parameters + ] | first // {} + ) as $pull_request_parameters + | ( + [ + $reviews[][][]? + | {reviewer: .user.login, state, submitted_at, commit_id} + | select(.submitted_at != null) + | select(.reviewer != $pr[0].user.login) + | select(.reviewer as $reviewer | + any($collaborators[][]?; + .login == $reviewer and + (.permissions.push == true or + .permissions.maintain == true or + .permissions.admin == true))) + ] + | group_by(.reviewer) + | map(sort_by(.submitted_at) | last) + | map(select(.state == "APPROVED" and .commit_id == $head)) + ) as $current_approvals + | ($pull_request_parameters.required_approving_review_count // 0) as $required_review_count + | ($pull_request_parameters.require_last_push_approval // false) as $require_last_push_approval + | { + head_sha: $head, + base_sha: $base, + required_status_checks: { + check_runs: [$checks[][].check_runs[]?], + legacy_statuses: [$statuses[][][]?] + }, + workflow_runs: [$workflow_runs[][].workflow_runs[]?], + counted_approvals: ($current_approvals | length), + required_approving_review_count: $required_review_count, + require_last_push_approval: $require_last_push_approval, + last_push_approval_authority: ( + if $require_last_push_approval == true + then "github_rule_evaluation_required" + else "not_required" + end + ), + approval_gate_satisfied: ( + if $pull_request_parameters.require_last_push_approval == true then false + else (($current_approvals | length) >= $required_review_count) + end + ), + required_workflows: [ + $rules[][]? + | select(.type == "workflows") + | .parameters.workflows[] + ], + unresolved_threads: [ + $threads[][].data.repository.pullRequest.reviewThreads.nodes[]? + | select(.isResolved == false and .isOutdated == false) + ] + }' > "$VERDICT_TMP" + + RECHECKED_PR_JSON="$EVIDENCE_DIR/pr-${PR}-rechecked.json" + RECHECKED_HEAD_SHA=$(gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" \ + | tee "$RECHECKED_PR_JSON" \ + | jq -r '.head.sha') + RECHECKED_BASE_SHA=$(jq -r '.base.sha' "$RECHECKED_PR_JSON") + if [[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" && "$RECHECKED_BASE_SHA" == "$BASE_SHA" ]]; then + mv "$VERDICT_TMP" "$VERDICT_PATH" + mv "$RECHECKED_PR_JSON" "$PR_JSON" + STABLE_HEAD=true + break + fi + rm -f "$VERDICT_TMP" "$RECHECKED_PR_JSON" + printf 'Discarding moving head/base evidence for PR #%s (head %s -> %s, base %s -> %s) and retrying.\n' \ + "$PR" "$HEAD_SHA" "$RECHECKED_HEAD_SHA" "$BASE_SHA" "$RECHECKED_BASE_SHA" >&2 + done + if [[ "$STABLE_HEAD" != true ]]; then + rm -f "$EVIDENCE_DIR"/pr-${PR}-*.json + printf 'Unable to collect stable exact-head/base evidence for PR #%s after 3 attempts.\n' "$PR" >&2 + exit 1 + fi +done +``` + +The branch-scoped rules response determines the active rules affecting `main`; each PR's exact `HEAD_SHA` then determines which check runs, legacy statuses, workflow runs, reviews, and unresolved threads are current. The saved merge verdict binds counted approvals to the latest review per eligible collaborator, excludes the PR author, and requires `APPROVED` on the exact head. It deliberately does **not** infer GitHub's actual last-push actor from commit author or committer metadata: when `require_last_push_approval` is active, this portable evidence procedure records `github_rule_evaluation_required` and keeps `approval_gate_satisfied` false until GitHub's authoritative rule evaluation is consulted. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. Evidence is retained only when both `RECHECKED_HEAD_SHA` and `RECHECKED_BASE_SHA` match the collected values; a moving head or base discards the temporary verdict, and three failed attempts leave no unstable merge verdict. + +For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. diff --git a/docs/traceability/mcp-authority-route.md b/docs/traceability/mcp-authority-route.md new file mode 100644 index 000000000..94f181ed4 --- /dev/null +++ b/docs/traceability/mcp-authority-route.md @@ -0,0 +1,58 @@ +# MCP 2026-07-28 authority-route traceability + +- **`tools/call` capability maturity:** `IMPLEMENTED_ON_PROTECTED_MAIN` +- **`tools/list` capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` +- **Protected-main owning work:** merged PR #168 `feat(mcp): bind stateless tool routing to typed actions` +- **Active follow-on:** PR #170 `feat(mcp): expose conservative tools list cache contract` +- **Complete MCP adapter status:** `PLANNED` +- **Governing decision:** ADR 0107 + +## Scope + +Protected main at `b05d5acca82b9d916ada2c8e82f59f92a89817e1` contains the bounded Rust control-plane foundation for MCP `2026-07-28` `tools/call` routing that merged through PR #168. It validates the represented stateless routing envelope, bounds and syntax-validates both attacker-controlled method fields and both attacker-controlled tool-name fields before correlation, maps only an explicit reviewed `originweave.*` catalog to existing typed `ActionKind` values, derives discovery metadata from the same catalog, and rejects route/action mismatch before ordinary deterministic policy evaluation. Methods are nonempty reviewed-ASCII routing tokens of at most 64 bytes; tool names are nonempty reviewed-ASCII identifiers of at most 128 bytes. Invalid method metadata is rejected distinctly from a bounded but unsupported MCP method. + +A successful `ValidatedMcpToolCall` proves routing integrity only. It grants no capability, origin, approval, secret, browser, tenant, persistence, network, or evidence authority. `originweave_policy::evaluate_mcp` still delegates to the ordinary policy evaluator after the route/action match. + +Active PR #170 builds on that protected-main catalog with a conservative typed `tools/list` request/result boundary. Its current branch requires matching MCP protocol metadata, required client-capability presence, bounded and syntax-validated routing/body methods, exact `tools/list` routing, and no caller-supplied cursor because the fixed catalog issues none. Its result is one complete page with zero freshness, private cache scope, and no continuation cursor. This active-PR slice remains non-shipped until it reaches protected main and does not grant any OriginWeave action authority. + +## Product-status reconciliation + +`docs/PRD.md` PRD-INT-004 and `docs/TRD.md` Section 12 intentionally remain **Planned** at the complete-adapter level. That status is not contradicted by the bounded `tools/call` foundation now on protected main or by active PR #170: both are reusable control-plane contracts below the complete product adapter. `README.md` and `CHANGELOG.md` distinguish protected-main routing from the active discovery refinement, and ADR 0107 records the protocol/version and authority boundary. + +The following remain outside protected main and PR #170 and must not be inferred from either: + +- Streamable HTTP transport parsing and header materialization; +- JSON-RPC/HTTP response serialization of the typed discovery page; +- OAuth and authenticated MCP deployment policy; +- browser-control I/O or BiDi/CDP/WebMCP translation; +- secret materialization or broker transport; +- persistence, durable audit storage, or WARC/PROV export; +- general pagination/subscription state beyond the fixed no-cursor catalog; and +- an OriginWeave Protocol version transition. + +## Version boundary + +The protected-main routing foundation and active discovery refinement accept only protocol generation `2026-07-28`. MCP versioning is independent of the OriginWeave Protocol. A later MCP revision does not silently change OriginWeave action, risk, capability, approval, secret, origin, tenant, browser, or evidence semantics. + +The reviewed primary source is: + +Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://modelcontextprotocol.io/specification/2026-07-28 + +The canonical bibliography remains `docs/doctoring.md`. + +## Executable evidence + +Protected-main PR #168 production/test surfaces include: + +- `crates/originweave-core/src/mcp.rs` — bounded deterministic catalog plus method/tool routing validation in the `ValidatedMcpToolCall` primitive; +- `crates/originweave-core/tests/mcp_authority_route.rs` — mapping, exact method/tool bounds, empty/oversized/malformed inputs, version/method/header-body correlation, and error-contract evidence; +- `crates/originweave-policy/src/lib.rs` — `evaluate_mcp` route/action guard before normal policy evaluation; and +- `crates/originweave-policy/tests/mcp_route_binding.rs` — confused-deputy and policy-preservation evidence. + +Active PR #170 additionally exercises its discovery contract in `crates/originweave-core/tests/mcp_tools_list_cache.rs`, including result/cache semantics, required protocol/client metadata, bounded protocol and method validation, routing correlation, cursor rejection, and public error contracts. + +Exact current-head CI/security/review evidence must be regenerated after every branch mutation. Protected-main evidence proves only the merged `tools/call` foundation; predecessor or protected-main results are not current-head proof for active PR #170. + +## Promotion rule + +The bounded `tools/call` routing foundation is already `IMPLEMENTED_ON_PROTECTED_MAIN`. The `tools/list` discovery refinement may change to `IMPLEMENTED_ON_PROTECTED_MAIN` only after PR #170 reaches protected `main` under live governance and exact-head acceptance. Neither promotion makes the complete MCP adapter implemented; each remaining transport/runtime boundary requires its own integrated evidence. diff --git a/tests/fixtures/agent_task_basic/index.html b/tests/fixtures/agent_task_basic/index.html new file mode 100644 index 000000000..510b239f1 --- /dev/null +++ b/tests/fixtures/agent_task_basic/index.html @@ -0,0 +1,42 @@ + + + + + + OriginWeave controlled Agent Task fixture + + +
+

Controlled Agent Task

+

This page is synthetic test data for deterministic browser integration.

+ +
+ + + +
+ + idle + + +
+ + + + diff --git a/tests/test_agent_task_fixture_contract.py b/tests/test_agent_task_fixture_contract.py new file mode 100644 index 000000000..2565a35c7 --- /dev/null +++ b/tests/test_agent_task_fixture_contract.py @@ -0,0 +1,137 @@ +"""Fail-first contract for the controlled Chromium Agent Task fixture.""" + +from __future__ import annotations + +from html.parser import HTMLParser +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests" / "fixtures" / "agent_task_basic" / "index.html" + + +def _is_credential_input(attributes: dict[str, str | None]) -> bool: + """Return whether parsed input attributes describe a credential surface.""" + + input_type = (attributes.get("type") or "").strip().lower() + if input_type == "password": + return True + + autocomplete = (attributes.get("autocomplete") or "").strip().lower() + autocomplete_tokens = autocomplete.split() + return any( + token == "one-time-code" or "password" in token + for token in autocomplete_tokens + ) + + +class _FixtureParser(HTMLParser): + """Collect the small semantic surface required by the deterministic fixture.""" + + def __init__(self) -> None: + super().__init__() + self.ids: set[str] = set() + self.labels_for: set[str] = set() + self.input_names: set[str] = set() + self.input_attributes: list[dict[str, str | None]] = [] + self.button_types: set[str] = set() + self.hidden_injection_markers = 0 + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + attributes = dict(attrs) + element_id = attributes.get("id") + if element_id: + self.ids.add(element_id) + if tag == "label" and attributes.get("for"): + self.labels_for.add(attributes["for"]) + if tag == "input": + self.input_attributes.append(attributes) + if attributes.get("name"): + self.input_names.add(attributes["name"]) + if tag == "button" and attributes.get("type"): + self.button_types.add(attributes["type"]) + if ( + attributes.get("data-originweave-untrusted") == "prompt-injection" + and "hidden" in attributes + and attributes.get("aria-hidden") == "true" + ): + self.hidden_injection_markers += 1 + + +class AgentTaskFixtureContractTests(unittest.TestCase): + """Require one deterministic semantic workflow for the first browser slice.""" + + def setUp(self) -> None: + """Load the checked-in fixture once for each independent contract.""" + + self.html = FIXTURE.read_text(encoding="utf-8") + self.parser = _FixtureParser() + self.parser.feed(self.html) + + def test_fixture_exposes_semantic_form_and_observable_post_condition(self) -> None: + """The fixture must support role/name discovery and a deterministic state change.""" + + self.assertIn("task-text", self.parser.ids) + self.assertIn("task-text", self.parser.labels_for) + self.assertIn("task_text", self.parser.input_names) + self.assertIn("submit", self.parser.button_types) + self.assertIn("task-result", self.parser.ids) + self.assertIn('data-state="idle"', self.html) + self.assertIn('result.dataset.state = "submitted"', self.html) + self.assertIn("result.textContent = taskText.value", self.html) + + def test_fixture_contains_explicit_untrusted_hidden_prompt_injection(self) -> None: + """A later real-browser regression needs hostile hidden page content to ignore.""" + + self.assertEqual(self.parser.hidden_injection_markers, 1) + self.assertIn("UNTRUSTED_PAGE_INSTRUCTION", self.html) + self.assertIn("request new browser capabilities", self.html) + + def test_hidden_injection_requires_the_actual_hidden_attribute(self) -> None: + """ARIA metadata alone must not satisfy the hidden-injection fixture contract.""" + + parser = _FixtureParser() + parser.feed( + "" + "" + ) + self.assertEqual(parser.hidden_injection_markers, 1) + + def test_fixture_is_synthetic_and_has_no_credential_fields(self) -> None: + """The controlled workflow must not require or imitate real secret collection.""" + + for attributes in self.parser.input_attributes: + with self.subTest(attributes=attributes): + self.assertFalse(_is_credential_input(attributes)) + + lowered = self.html.lower() + for forbidden in ("api_key", "secret_key"): + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, lowered) + + def test_credential_detection_is_quote_independent(self) -> None: + """Parsed credential semantics must reject single-quoted and tokenized forms.""" + + for html in ( + "", + "", + "", + "", + "", + ): + with self.subTest(html=html): + parser = _FixtureParser() + parser.feed(html) + self.assertEqual(len(parser.input_attributes), 1) + self.assertTrue(_is_credential_input(parser.input_attributes[0])) + + parser = _FixtureParser() + parser.feed("") + self.assertEqual(len(parser.input_attributes), 1) + self.assertFalse(_is_credential_input(parser.input_attributes[0])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_doctoring_reference_contract.py b/tests/test_doctoring_reference_contract.py new file mode 100644 index 000000000..bdeded44f --- /dev/null +++ b/tests/test_doctoring_reference_contract.py @@ -0,0 +1,28 @@ +"""Regression contracts for standards references that bind OriginWeave design claims.""" + +from __future__ import annotations + +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DOCTORING = ROOT / "docs" / "doctoring.md" + + +class DoctoringReferenceContractTests(unittest.TestCase): + """Keep cited primary-standard authorship aligned with the canonical source.""" + + def test_rfc_5280_reference_uses_canonical_author_initials(self) -> None: + """RFC 5280 must credit Sharon Boeyen as S. Boeyen, matching RFC Editor metadata.""" + text = DOCTORING.read_text(encoding="utf-8") + expected = ( + "Cooper, D., Santesson, S., Farrell, S., Boeyen, S., Housley, R., & Polk, W. " + "(2008). *Internet X.509 public key infrastructure certificate and certificate " + "revocation list (CRL) profile* (RFC 5280). Internet Engineering Task Force. " + "https://doi.org/10.17487/RFC5280" + ) + self.assertIn(expected, text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gap_snapshot_inventory_consistency.py b/tests/test_gap_snapshot_inventory_consistency.py new file mode 100644 index 000000000..0daca1f85 --- /dev/null +++ b/tests/test_gap_snapshot_inventory_consistency.py @@ -0,0 +1,58 @@ +"""Regression contracts for the current dated product-gap inventory snapshot.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +BASELINE = ROOT / "docs" / "product-technical-gap-baseline.md" +CHANGELOG = ROOT / "CHANGELOG.md" + + +class GapSnapshotInventoryConsistencyTests(unittest.TestCase): + """Prevent one dated snapshot from carrying contradictory live PR totals.""" + + @classmethod + def setUpClass(cls) -> None: + cls.baseline = BASELINE.read_text(encoding="utf-8") + cls.changelog = CHANGELOG.read_text(encoding="utf-8") + + def test_current_baseline_inventory_matches_the_verified_snapshot(self) -> None: + """The current snapshot must use the exact 126/54/72 inventory observation.""" + current = self.baseline.split("### Open pull requests", 1)[1].split( + "#### 2026-08-26 maintenance-loop record", 1 + )[0] + for marker in ( + "126 open pull requests", + "54 non-draft", + "72 draft", + ): + with self.subTest(marker=marker): + self.assertIn(marker, current) + + for stale in ( + "128 open pull requests", + "74 draft", + "153 open pull requests", + "114 draft", + ): + with self.subTest(stale=stale): + self.assertNotIn(stale, current) + + def test_unreleased_changelog_uses_one_current_inventory(self) -> None: + """The Unreleased current snapshot must agree before and inside Added.""" + unreleased = self.changelog.split("## [Unreleased]", 1)[1] + preamble, remainder = unreleased.split("### Added", 1) + added = remainder.split("### Changed", 1)[0] + + expected = "126 open pull requests (54 ready, 72 draft)" + self.assertIn(expected, preamble) + self.assertIn(expected, added) + self.assertNotIn("128 open pull requests (54 ready, 74 draft)", preamble) + self.assertNotIn("153 open pull requests (39 ready, 114 draft)", added) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py new file mode 100644 index 000000000..1c24fe674 --- /dev/null +++ b/tests/test_product_completion_gap_contract.py @@ -0,0 +1,123 @@ +"""Regression contract for the dated commercial-completion gap baseline.""" + +from __future__ import annotations + +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +BASELINE = ROOT / "docs/product-technical-gap-baseline.md" + + +class ProductCompletionGapContractTests(unittest.TestCase): + """Keep the exact repository snapshot and completion tracks reviewable.""" + + def test_baseline_records_current_inventory_and_completion_issues(self) -> None: + """The dated baseline must not retain superseded queue counts or omit buyer tracks.""" + text = BASELINE.read_text(encoding="utf-8") + + for phrase in ( + "126 open pull requests", + "54 non-draft", + "72 draft", + "2026-08-24 158-PR snapshot", + "#198", + "#199", + "#200", + "#201", + "#202", + "#203", + "durable WARC/PROV replay", + "stable BAP/MCP runtime API", + "signed cross-platform Chromium distribution", + "enterprise control and experience plane", + "commercial acceptance gate", + ): + with self.subTest(phrase=phrase): + self.assertIn(phrase, text) + + for stale_phrase in ( + "100 open pull requests", + "22 non-draft", + "78 draft", + "148 open pull requests", + "79 draft PRs", + "40 non-draft", + "110 draft", + "150 open pull requests", + "prior 150-PR snapshot", + "128 open pull requests", + "74 draft", + ): + with self.subTest(stale_phrase=stale_phrase): + self.assertNotIn(stale_phrase, text) + + def test_active_github_approval_rule_is_not_documented_as_bypassable(self) -> None: + """An active counted-approval rule must stop merge without an eligible approver.""" + text = BASELINE.read_text(encoding="utf-8") + + self.assertIn("eligible non-author", text) + self.assertIn("reviewer-provisioning gap", text) + self.assertNotIn("owner-directed administrative merge", text) + + def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> None: + """The evidence procedure must paginate the queue and inspect each exact PR head.""" + text = BASELINE.read_text(encoding="utf-8") + evidence = text.split("## Evidence commands", 1)[1].split("\n## ", 1)[0] + shell = evidence.split("```bash", 1)[1].split("```", 1)[0] + + for phrase in ( + "--paginate --slurp 'repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100'", + "set -euo pipefail", + 'EVIDENCE_DIR="$(mktemp -d /tmp/originweave-evidence.XXXXXX)"', + '"$EVIDENCE_DIR/open-pr-pages.json"', + "jq '[.[][]]' \"$EVIDENCE_DIR/open-pr-pages.json\"", + '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR"', + '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100"', + '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/statuses?per_page=100"', + '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100"', + '"repos/ContextualWisdomLab/OriginWeave/actions/runs?head_sha=$HEAD_SHA&per_page=100"', + "check_runs: [$checks[][].check_runs[]?],", + "legacy_statuses: [$statuses[][][]?]", + "workflow_runs: [$workflow_runs[][].workflow_runs[]?],", + "reviewThreads(first: 100, after: $endCursor)", + "rules/branches/main?per_page=100", + '"$EVIDENCE_DIR/main-branch-rule-pages.json"', + '"$EVIDENCE_DIR/collaborator-pages.json"', + '"$EVIDENCE_DIR/collaborators.json"', + '"$EVIDENCE_DIR/pr-${PR}-merge-verdict.json.tmp"', + '.state == "APPROVED"', + ".submitted_at != null", + ".commit_id == $head", + "group_by(.reviewer)", + "required_approving_review_count", + "require_last_push_approval", + "last_push_approval_authority", + '"github_rule_evaluation_required"', + "if $pull_request_parameters.require_last_push_approval == true then false", + "$pr[0].user.login", + '.type == "workflows"', + ".parameters.workflows", + "required_status_checks", + '"$EVIDENCE_DIR/pr-${PR}-merge-verdict.json"', + "for ATTEMPT in 1 2 3; do", + "RECHECKED_HEAD_SHA=", + "RECHECKED_BASE_SHA=", + 'if [[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" && "$RECHECKED_BASE_SHA" == "$BASE_SHA" ]]; then', + ): + with self.subTest(phrase=phrase): + self.assertIn(phrase, shell) + + self.assertNotIn("while :; do", shell) + self.assertNotIn("/tmp/originweave-open-pr", shell) + self.assertNotIn("check_runs: [$checks[]?.check_runs[]?],", shell) + self.assertNotIn("legacy_statuses: [$statuses[][]?]", shell) + self.assertNotIn("workflow_runs: [$workflow_runs[]?.workflow_runs[]?],", shell) + self.assertNotIn("$reviews[][]?\n | select(.state", shell) + self.assertNotIn("head-commit.json", shell) + self.assertNotIn("$head_commit[0].committer.login", shell) + self.assertNotIn("$head_commit[0].author.login", shell) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rust_toolchain_contract.py b/tests/test_rust_toolchain_contract.py new file mode 100644 index 000000000..85d83044d --- /dev/null +++ b/tests/test_rust_toolchain_contract.py @@ -0,0 +1,43 @@ +"""Regression contracts for the reproducible Rust compiler baseline.""" + +from __future__ import annotations + +import tomllib +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +RUST_TOOLCHAIN = REPOSITORY_ROOT / "rust-toolchain.toml" +CI_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "ci.yml" +HOURLY_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "hourly-product-development.yml" +DEPENDABOT = REPOSITORY_ROOT / ".github" / "dependabot.yml" + + +class RustToolchainContractTests(unittest.TestCase): + """Keep stable builds reproducible and branch coverage intentionally fresh.""" + + def test_stable_toolchain_is_exact_and_automatically_tracked(self) -> None: + """The stable compiler changes only through a reviewable manifest update.""" + + manifest = tomllib.loads(RUST_TOOLCHAIN.read_text(encoding="utf-8")) + self.assertEqual(manifest["toolchain"]["channel"], "1.97.1") + + dependabot = DEPENDABOT.read_text(encoding="utf-8") + self.assertIn('package-ecosystem: "rust-toolchain"', dependabot) + self.assertIn('directory: "/"', dependabot) + self.assertIn('interval: "weekly"', dependabot) + + def test_branch_coverage_uses_one_current_date_pinned_nightly(self) -> None: + """Every branch-coverage command uses the same reviewed nightly snapshot.""" + + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + self.assertEqual(workflow.count("nightly-2026-08-18"), 3) + self.assertNotIn("nightly-2026-08-01", workflow) + + hourly_workflow = HOURLY_WORKFLOW.read_text(encoding="utf-8") + self.assertEqual(hourly_workflow.count("nightly-2026-08-18"), 2) + self.assertNotIn("nightly-2026-08-01", hourly_workflow) + +if __name__ == "__main__": # pragma: no cover + unittest.main() From 89708cf5e474f7701513b84a1356a8ce1699bef5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 12:10:41 +0900 Subject: [PATCH 557/570] repair(evidence): preserve product contracts with browser provenance --- .../src/extraction_schema.rs | 297 ++++++++++++++++ crates/originweave-evidence/src/lib.rs | 39 +++ .../src/sensitive_access.rs | 5 +- .../src/sensitive_handle_lifecycle.rs | 144 ++++++++ crates/originweave-evidence/tests/evidence.rs | 4 + .../tests/extraction_normalization.rs | 77 +++++ .../tests/extraction_schema.rs | 326 ++++++++++++++++++ .../tests/extraction_schema_error_contract.rs | 48 +++ .../tests/extraction_source_channel_set.rs | 40 +++ .../tests/sensitive_handle_access_binding.rs | 114 ++++++ .../sensitive_handle_lifecycle_evidence.rs | 142 ++++++++ 11 files changed, 1235 insertions(+), 1 deletion(-) create mode 100644 crates/originweave-evidence/src/extraction_schema.rs create mode 100644 crates/originweave-evidence/src/sensitive_handle_lifecycle.rs create mode 100644 crates/originweave-evidence/tests/extraction_normalization.rs create mode 100644 crates/originweave-evidence/tests/extraction_schema.rs create mode 100644 crates/originweave-evidence/tests/extraction_schema_error_contract.rs create mode 100644 crates/originweave-evidence/tests/extraction_source_channel_set.rs create mode 100644 crates/originweave-evidence/tests/sensitive_handle_access_binding.rs create mode 100644 crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs diff --git a/crates/originweave-evidence/src/extraction_schema.rs b/crates/originweave-evidence/src/extraction_schema.rs new file mode 100644 index 000000000..14a86a24c --- /dev/null +++ b/crates/originweave-evidence/src/extraction_schema.rs @@ -0,0 +1,297 @@ +//! Versioned schema contracts for typed evidence extraction. +//! +//! These value objects describe what may be extracted and which reviewed +//! evidence channels may support each field. They do not read browser data, +//! disclose protected values, persist artifacts, execute models, or grant any +//! browser, network, secret, approval, or storage authority. + +use std::{collections::BTreeSet, fmt}; + +/// Maximum encoded byte length for an extraction schema or field identifier. +pub const MAX_EXTRACTION_IDENTIFIER_BYTES: usize = 128; +/// Maximum number of fields admitted by one extraction schema. +pub const MAX_EXTRACTION_FIELD_COUNT: usize = 256; + +/// The typed value contract for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionValueType { + /// Bounded textual data. + Text, + /// A whole-number value. + Integer, + /// A decimal numeric value. + Decimal, + /// A boolean value. + Boolean, + /// A timestamp value whose concrete normalization is defined by the schema version. + Timestamp, +} + +/// The number of values admitted for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionCardinality { + /// Exactly one value is admitted. + One, + /// Zero or one value is admitted. + ZeroOrOne, + /// A bounded collection may be admitted by a later extraction runtime. + Many, +} + +/// A reviewed evidence channel that may support an extracted value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionSourceChannel { + /// A semantic browser node with an independently validated identity. + SemanticNode, + /// Embedded structured metadata such as JSON-LD, RDFa, or Microdata. + StructuredData, + /// A bounded table-cell observation. + TableCell, + /// A bounded network response whose origin and response identity are independently verified. + NetworkResponse, + /// A separately approved model interpretation backed by explicit evidence identifiers. + ModelInterpretation, +} + +/// A deterministic normalization rule declared for one extracted field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExtractionNormalizationRule { + /// Preserve the typed source value without text normalization. + Verbatim, + /// Trim surrounding whitespace from a textual value. + TrimTextWhitespace, + /// Normalize a timestamp into an RFC 3339 UTC representation. + Rfc3339Utc, +} + +/// A validation failure while constructing an extraction schema contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtractionSchemaError { + /// A schema or field identifier was empty or outside the accepted identifier grammar. + InvalidIdentifier, + /// An identifier or field collection exceeded its bounded limit. + LimitExceeded, + /// A field's required flag contradicted its declared cardinality. + InvalidCardinalityRequirement, + /// A field did not declare any reviewed source channel. + MissingSourceChannel, + /// A field declared the same source channel more than once. + DuplicateSourceChannel, + /// The declared normalization rule was incompatible with the field value type. + InvalidNormalizationRule, + /// A schema did not contain any field definitions. + MissingField, + /// A schema declared the same field identifier more than once. + DuplicateField, +} + +impl fmt::Display for ExtractionSchemaError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidIdentifier => "invalid extraction schema or field identifier", + Self::LimitExceeded => "extraction schema limit exceeded", + Self::InvalidCardinalityRequirement => { + "extraction field required flag is incompatible with the declared cardinality" + } + Self::MissingSourceChannel => "extraction field requires at least one source channel", + Self::DuplicateSourceChannel => "extraction field contains a duplicate source channel", + Self::InvalidNormalizationRule => { + "extraction normalization rule is incompatible with the field value type" + } + Self::MissingField => "extraction schema requires at least one field", + Self::DuplicateField => "extraction schema contains a duplicate field identifier", + }) + } +} + +impl std::error::Error for ExtractionSchemaError {} + +/// One typed field declared by a versioned extraction schema. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractionField { + identifier: String, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + normalization_rule: ExtractionNormalizationRule, + source_channels: Vec, +} + +impl ExtractionField { + /// Validate and construct one extraction field contract with verbatim normalization. + pub fn new( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + source_channels: &[ExtractionSourceChannel], + ) -> Result { + Self::new_with_normalization( + identifier, + value_type, + cardinality, + required, + ExtractionNormalizationRule::Verbatim, + source_channels, + ) + } + + /// Validate and construct one extraction field with an explicit normalization rule. + pub fn new_with_normalization( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + normalization_rule: ExtractionNormalizationRule, + source_channels: &[ExtractionSourceChannel], + ) -> Result { + validate_identifier(identifier)?; + + let cardinality_requirement_is_compatible = match cardinality { + ExtractionCardinality::One => required, + ExtractionCardinality::ZeroOrOne => !required, + ExtractionCardinality::Many => true, + }; + if !cardinality_requirement_is_compatible { + return Err(ExtractionSchemaError::InvalidCardinalityRequirement); + } + + if source_channels.is_empty() { + return Err(ExtractionSchemaError::MissingSourceChannel); + } + + let normalization_is_compatible = match normalization_rule { + ExtractionNormalizationRule::Verbatim => true, + ExtractionNormalizationRule::TrimTextWhitespace => { + value_type == ExtractionValueType::Text + } + ExtractionNormalizationRule::Rfc3339Utc => value_type == ExtractionValueType::Timestamp, + }; + if !normalization_is_compatible { + return Err(ExtractionSchemaError::InvalidNormalizationRule); + } + + let mut seen_channels = BTreeSet::new(); + for source_channel in source_channels { + if !seen_channels.insert(*source_channel) { + return Err(ExtractionSchemaError::DuplicateSourceChannel); + } + } + + Ok(Self { + identifier: identifier.to_owned(), + value_type, + cardinality, + required, + normalization_rule, + source_channels: seen_channels.into_iter().collect(), + }) + } + + /// Return the stable field identifier. + #[must_use] + pub fn identifier(&self) -> &str { + &self.identifier + } + + /// Return the declared value type. + #[must_use] + pub const fn value_type(&self) -> ExtractionValueType { + self.value_type + } + + /// Return the declared cardinality. + #[must_use] + pub const fn cardinality(&self) -> ExtractionCardinality { + self.cardinality + } + + /// Return whether the field must be present in a conforming extraction result. + #[must_use] + pub const fn required(&self) -> bool { + self.required + } + + /// Return the deterministic normalization rule declared for this field. + #[must_use] + pub const fn normalization_rule(&self) -> ExtractionNormalizationRule { + self.normalization_rule + } + + /// Return the reviewed source channels that may support this field. + #[must_use] + pub fn source_channels(&self) -> &[ExtractionSourceChannel] { + &self.source_channels + } +} + +/// A bounded versioned collection of typed extraction-field contracts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractionSchema { + version: String, + fields: Vec, +} + +impl ExtractionSchema { + /// Validate and construct one versioned extraction schema. + pub fn new(version: &str, fields: Vec) -> Result { + validate_identifier(version)?; + if fields.is_empty() { + return Err(ExtractionSchemaError::MissingField); + } + if fields.len() > MAX_EXTRACTION_FIELD_COUNT { + return Err(ExtractionSchemaError::LimitExceeded); + } + + let mut field_identifiers = BTreeSet::new(); + for field in &fields { + if !field_identifiers.insert(field.identifier()) { + return Err(ExtractionSchemaError::DuplicateField); + } + } + + Ok(Self { + version: version.to_owned(), + fields, + }) + } + + /// Return the immutable schema version identifier. + #[must_use] + pub fn version(&self) -> &str { + &self.version + } + + /// Return the schema's ordered field definitions. + #[must_use] + pub fn fields(&self) -> &[ExtractionField] { + &self.fields + } + + /// Find one field by its stable identifier. + #[must_use] + pub fn field(&self, identifier: &str) -> Option<&ExtractionField> { + self.fields + .iter() + .find(|field| field.identifier() == identifier) + } +} + +fn validate_identifier(identifier: &str) -> Result<(), ExtractionSchemaError> { + if identifier.len() > MAX_EXTRACTION_IDENTIFIER_BYTES { + return Err(ExtractionSchemaError::LimitExceeded); + } + + let mut bytes = identifier.bytes(); + let Some(first_byte) = bytes.next() else { + return Err(ExtractionSchemaError::InvalidIdentifier); + }; + if !first_byte.is_ascii_lowercase() { + return Err(ExtractionSchemaError::InvalidIdentifier); + } + if bytes.any(|byte| !matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')) { + return Err(ExtractionSchemaError::InvalidIdentifier); + } + + Ok(()) +} diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index 406c8be03..747dc20f0 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -7,13 +7,23 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] +mod extraction_schema; mod sensitive_access; +mod sensitive_handle_lifecycle; +pub use extraction_schema::{ + ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchema, + ExtractionSchemaError, ExtractionSourceChannel, ExtractionValueType, + MAX_EXTRACTION_FIELD_COUNT, MAX_EXTRACTION_IDENTIFIER_BYTES, +}; pub use sensitive_access::{ MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, SensitiveAccessOutcome, SensitiveEvidenceError, }; +pub use sensitive_handle_lifecycle::{ + SensitiveHandleLifecycleEvidence, SensitiveHandleLifecycleEvidenceInput, +}; use std::collections::BTreeMap; @@ -296,6 +306,9 @@ fn validate_path(path: &str) -> Result<(), EvidenceError> { index += 3; continue; } + if !is_rfc3986_pchar(byte) { + return Err(EvidenceError::InvalidPath); + } segment.push(byte); index += 1; } @@ -305,6 +318,32 @@ fn validate_path(path: &str) -> Result<(), EvidenceError> { Ok(()) } +const fn is_rfc3986_pchar(byte: u8) -> bool { + matches!( + byte, + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + ) +} + const fn hexadecimal_value(byte: u8) -> Option { match byte { b'0'..=b'9' => Some(byte - b'0'), diff --git a/crates/originweave-evidence/src/sensitive_access.rs b/crates/originweave-evidence/src/sensitive_access.rs index 9123119f7..24cb43047 100644 --- a/crates/originweave-evidence/src/sensitive_access.rs +++ b/crates/originweave-evidence/src/sensitive_access.rs @@ -297,7 +297,10 @@ fn validate_fields(field_ids: &[String]) -> Result<(), SensitiveEvidenceError> { Ok(()) } -fn valid_identifier(value: &str) -> bool { +/// Return whether `value` is a non-empty identifier of at most +/// `MAX_SENSITIVE_IDENTIFIER_BYTES` ASCII bytes, contains at least one +/// alphanumeric byte, and otherwise uses only `.`, `_`, `:`, or `-` punctuation. +pub(crate) fn valid_identifier(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_SENSITIVE_IDENTIFIER_BYTES && value.bytes().any(|byte| byte.is_ascii_alphanumeric()) diff --git a/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs new file mode 100644 index 000000000..f61c8527f --- /dev/null +++ b/crates/originweave-evidence/src/sensitive_handle_lifecycle.rs @@ -0,0 +1,144 @@ +//! Credential-free lifecycle evidence for opaque sensitive-value handles. +//! +//! A trusted broker can use this value object to record when a handle was +//! issued, when it expires, how many uses it permits, how many resolutions were +//! observed, and when it was revoked. The lifecycle retains the complete +//! credential-free sensitive-access receipt that authorized opaque-handle use, +//! while intentionally excluding the opaque handle token and protected value. + +use crate::sensitive_access::{ + SensitiveAccessEvidence, SensitiveAccessOutcome, SensitiveEvidenceError, +}; + +/// Unvalidated metadata describing one opaque sensitive-value handle lifecycle. +/// +/// The embedded access receipt binds the lifecycle to the tenant, actor, task, +/// field set, purpose, destination, classification, policy version, and exact +/// opaque-handle authorization without carrying protected values. When the access +/// receipt carries a retention deadline, the handle must expire no later than +/// that deadline so derived opaque authority cannot outlive its governing receipt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SensitiveHandleLifecycleEvidenceInput { + /// Credential-free access receipt that authorized this opaque handle. + pub access_evidence: SensitiveAccessEvidence, + /// Trusted Unix epoch second when the handle was issued. + pub issued_epoch_seconds: u64, + /// Trusted Unix epoch second after which the handle is no longer valid. + /// + /// When the retained access receipt defines a retention deadline, this value + /// may equal but must not exceed that deadline. + pub expires_epoch_seconds: u64, + /// Maximum number of broker resolutions authorized for the handle. + pub maximum_uses: u32, + /// Number of broker resolutions already observed for the handle. + pub resolution_count: u32, + /// Trusted Unix epoch second when the handle was revoked, when applicable. + /// + /// A revocation recorded exactly at expiry is retained as a terminal audit + /// event even though it cannot extend or restore handle validity. + pub revoked_epoch_seconds: Option, +} + +/// Immutable credential-free evidence about one opaque handle lifecycle. +/// +/// The value retains the exact credential-free sensitive-access receipt that +/// authorized opaque-handle use, but deliberately excludes both the opaque +/// handle token and the secret or protected value that the broker can resolve. +/// Any receipt retention deadline also bounds the derived handle lifetime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SensitiveHandleLifecycleEvidence { + access_evidence: SensitiveAccessEvidence, + issued_epoch_seconds: u64, + expires_epoch_seconds: u64, + maximum_uses: u32, + resolution_count: u32, + revoked_epoch_seconds: Option, +} + +impl TryFrom for SensitiveHandleLifecycleEvidence { + type Error = SensitiveEvidenceError; + + fn try_from(input: SensitiveHandleLifecycleEvidenceInput) -> Result { + if input.access_evidence.outcome() != SensitiveAccessOutcome::OpaqueHandleOnly + || input.issued_epoch_seconds == 0 + || input.issued_epoch_seconds < input.access_evidence.decision_epoch_seconds() + || input.expires_epoch_seconds <= input.issued_epoch_seconds + || input + .access_evidence + .retention_deadline_epoch_seconds() + .is_some_and(|deadline| input.expires_epoch_seconds > deadline) + || input.maximum_uses == 0 + || input.resolution_count > input.maximum_uses + || input.revoked_epoch_seconds.is_some_and(|revoked| { + revoked < input.issued_epoch_seconds || revoked > input.expires_epoch_seconds + }) + { + return Err(SensitiveEvidenceError::InvalidLifecycle); + } + + Ok(Self { + access_evidence: input.access_evidence, + issued_epoch_seconds: input.issued_epoch_seconds, + expires_epoch_seconds: input.expires_epoch_seconds, + maximum_uses: input.maximum_uses, + resolution_count: input.resolution_count, + revoked_epoch_seconds: input.revoked_epoch_seconds, + }) + } +} + +impl SensitiveHandleLifecycleEvidence { + /// Return the credential-free access receipt that authorized this opaque handle. + #[must_use] + pub const fn access_evidence(&self) -> &SensitiveAccessEvidence { + &self.access_evidence + } + + /// Return the originating sensitive-data access request identifier. + #[must_use] + pub fn request_id(&self) -> &str { + self.access_evidence.request_id() + } + + /// Return the policy decision identifier associated with the handle. + #[must_use] + pub fn decision_id(&self) -> &str { + self.access_evidence.decision_id() + } + + /// Return the trusted handle issuance time as a Unix epoch second. + #[must_use] + pub const fn issued_epoch_seconds(&self) -> u64 { + self.issued_epoch_seconds + } + + /// Return the trusted handle expiry time as a Unix epoch second. + #[must_use] + pub const fn expires_epoch_seconds(&self) -> u64 { + self.expires_epoch_seconds + } + + /// Return the maximum number of broker resolutions authorized for the handle. + #[must_use] + pub const fn maximum_uses(&self) -> u32 { + self.maximum_uses + } + + /// Return the number of broker resolutions already observed for the handle. + #[must_use] + pub const fn resolution_count(&self) -> u32 { + self.resolution_count + } + + /// Return the trusted revocation time when the handle has been revoked. + #[must_use] + pub const fn revoked_epoch_seconds(&self) -> Option { + self.revoked_epoch_seconds + } + + /// Return whether trusted evidence records that this handle was revoked. + #[must_use] + pub const fn is_revoked(&self) -> bool { + self.revoked_epoch_seconds.is_some() + } +} diff --git a/crates/originweave-evidence/tests/evidence.rs b/crates/originweave-evidence/tests/evidence.rs index 2912180f1..48d49cbc4 100644 --- a/crates/originweave-evidence/tests/evidence.rs +++ b/crates/originweave-evidence/tests/evidence.rs @@ -80,6 +80,9 @@ fn network_evidence_rejects_non_path_inputs() { "/bad\npath", "/bad path", "/windows\\path", + "/[segment]", + "/raw|pipe", + "/raw-한글", ] { assert_eq!( NetworkEvidence::capture( @@ -128,6 +131,7 @@ fn provenance_rejects_credential_bearing_or_ambiguous_source_urls() { "https://example.com/bad\\path", "https://example.com/\n", "https://example.com/a/%2f/b", + "https://example.com/[segment]", ] { assert_eq!( ProvenanceRecord::new( diff --git a/crates/originweave-evidence/tests/extraction_normalization.rs b/crates/originweave-evidence/tests/extraction_normalization.rs new file mode 100644 index 000000000..63afd39e6 --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_normalization.rs @@ -0,0 +1,77 @@ +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionNormalizationRule, ExtractionSchemaError, + ExtractionSourceChannel, ExtractionValueType, +}; + +#[test] +fn extraction_fields_require_an_explicit_typed_normalization_rule() +-> Result<(), ExtractionSchemaError> { + let text = ExtractionField::new_with_normalization( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::TrimTextWhitespace, + &[ExtractionSourceChannel::SemanticNode], + )?; + assert_eq!( + text.normalization_rule(), + ExtractionNormalizationRule::TrimTextWhitespace + ); + + let timestamp = ExtractionField::new_with_normalization( + "captured_at", + ExtractionValueType::Timestamp, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::Rfc3339Utc, + &[ExtractionSourceChannel::NetworkResponse], + )?; + assert_eq!( + timestamp.normalization_rule(), + ExtractionNormalizationRule::Rfc3339Utc + ); + Ok(()) +} + +#[test] +fn extraction_fields_fail_closed_on_type_incompatible_normalization() { + assert_eq!( + ExtractionField::new_with_normalization( + "captured_at", + ExtractionValueType::Timestamp, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::TrimTextWhitespace, + &[ExtractionSourceChannel::NetworkResponse], + ), + Err(ExtractionSchemaError::InvalidNormalizationRule) + ); + assert_eq!( + ExtractionField::new_with_normalization( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + ExtractionNormalizationRule::Rfc3339Utc, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidNormalizationRule) + ); +} + +#[test] +fn existing_fields_default_to_verbatim_normalization() -> Result<(), ExtractionSchemaError> { + let field = ExtractionField::new( + "unit_price", + ExtractionValueType::Decimal, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::StructuredData], + )?; + assert_eq!( + field.normalization_rule(), + ExtractionNormalizationRule::Verbatim + ); + Ok(()) +} diff --git a/crates/originweave-evidence/tests/extraction_schema.rs b/crates/originweave-evidence/tests/extraction_schema.rs new file mode 100644 index 000000000..fc875ef0f --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_schema.rs @@ -0,0 +1,326 @@ +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionSchema, ExtractionSchemaError, + ExtractionSourceChannel, ExtractionValueType, MAX_EXTRACTION_FIELD_COUNT, + MAX_EXTRACTION_IDENTIFIER_BYTES, +}; + +fn field( + identifier: &str, + value_type: ExtractionValueType, + cardinality: ExtractionCardinality, + required: bool, + source_channels: &[ExtractionSourceChannel], +) -> Result { + ExtractionField::new( + identifier, + value_type, + cardinality, + required, + source_channels, + ) +} + +#[test] +fn schema_binds_versioned_typed_fields_to_explicit_source_channels() +-> Result<(), ExtractionSchemaError> { + let schema = ExtractionSchema::new( + "product-card-v1", + vec![ + field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::StructuredData, + ], + )?, + field( + "unit_price", + ExtractionValueType::Decimal, + ExtractionCardinality::ZeroOrOne, + false, + &[ + ExtractionSourceChannel::TableCell, + ExtractionSourceChannel::NetworkResponse, + ], + )?, + ], + )?; + + assert_eq!(schema.version(), "product-card-v1"); + assert_eq!(schema.fields().len(), 2); + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::identifier), + Some("product_name") + ); + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::value_type), + Some(ExtractionValueType::Text) + ); + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::cardinality), + Some(ExtractionCardinality::One) + ); + assert_eq!( + schema.field("product_name").map(ExtractionField::required), + Some(true) + ); + let expected_product_sources = [ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::StructuredData, + ]; + assert_eq!( + schema + .field("product_name") + .map(ExtractionField::source_channels), + Some(expected_product_sources.as_slice()) + ); + assert_eq!( + schema.field("unit_price").map(ExtractionField::value_type), + Some(ExtractionValueType::Decimal) + ); + assert_eq!( + schema.field("unit_price").map(ExtractionField::cardinality), + Some(ExtractionCardinality::ZeroOrOne) + ); + assert_eq!( + schema.field("unit_price").map(ExtractionField::required), + Some(false) + ); + assert!(schema.field("missing_field").is_none()); + Ok(()) +} + +#[test] +fn field_accepts_all_reviewed_value_and_source_channel_variants() +-> Result<(), ExtractionSchemaError> { + let cases = [ + ( + ExtractionValueType::Text, + ExtractionSourceChannel::SemanticNode, + ), + ( + ExtractionValueType::Integer, + ExtractionSourceChannel::StructuredData, + ), + ( + ExtractionValueType::Decimal, + ExtractionSourceChannel::TableCell, + ), + ( + ExtractionValueType::Boolean, + ExtractionSourceChannel::NetworkResponse, + ), + ( + ExtractionValueType::Timestamp, + ExtractionSourceChannel::ModelInterpretation, + ), + ]; + + for (index, (value_type, source_channel)) in cases.into_iter().enumerate() { + let field = field( + &format!("field_{index}"), + value_type, + ExtractionCardinality::Many, + false, + &[source_channel], + )?; + assert_eq!(field.value_type(), value_type); + assert_eq!(field.cardinality(), ExtractionCardinality::Many); + assert_eq!(field.source_channels(), &[source_channel]); + } + + let required_many = field( + "required_many", + ExtractionValueType::Text, + ExtractionCardinality::Many, + true, + &[ExtractionSourceChannel::SemanticNode], + )?; + assert!(required_many.required()); + Ok(()) +} + +#[test] +fn field_rejects_contradictory_required_cardinality_contracts() { + assert_eq!( + ExtractionField::new( + "optional_exactly_one", + ExtractionValueType::Text, + ExtractionCardinality::One, + false, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidCardinalityRequirement) + ); + assert_eq!( + ExtractionField::new( + "required_zero_or_one", + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidCardinalityRequirement) + ); +} + +#[test] +fn field_rejects_empty_malformed_or_overlong_identifiers() { + assert_eq!( + ExtractionField::new( + "", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + "Product Name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + "product name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + "1product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionField::new( + &"a".repeat(MAX_EXTRACTION_IDENTIFIER_BYTES + 1), + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + ), + Err(ExtractionSchemaError::LimitExceeded) + ); +} + +#[test] +fn field_requires_a_nonempty_duplicate_free_source_channel_set() { + assert_eq!( + ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[], + ), + Err(ExtractionSchemaError::MissingSourceChannel) + ); + assert_eq!( + ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::SemanticNode, + ], + ), + Err(ExtractionSchemaError::DuplicateSourceChannel) + ); +} + +#[test] +fn schema_rejects_invalid_version_empty_fields_duplicate_fields_and_field_overflow() +-> Result<(), ExtractionSchemaError> { + assert_eq!( + ExtractionSchema::new( + "Product Schema", + vec![field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )?] + ), + Err(ExtractionSchemaError::InvalidIdentifier) + ); + assert_eq!( + ExtractionSchema::new( + &"a".repeat(MAX_EXTRACTION_IDENTIFIER_BYTES + 1), + vec![field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )?], + ), + Err(ExtractionSchemaError::LimitExceeded) + ); + assert_eq!( + ExtractionSchema::new("product-card-v1", vec![]), + Err(ExtractionSchemaError::MissingField) + ); + + let duplicate = field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ExtractionSourceChannel::SemanticNode], + )?; + let duplicate_again = field( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::StructuredData], + )?; + assert_eq!( + ExtractionSchema::new("product-card-v1", vec![duplicate, duplicate_again]), + Err(ExtractionSchemaError::DuplicateField) + ); + + let too_many_fields = (0..=MAX_EXTRACTION_FIELD_COUNT) + .map(|index| { + field( + &format!("field_{index}"), + ExtractionValueType::Text, + ExtractionCardinality::ZeroOrOne, + false, + &[ExtractionSourceChannel::SemanticNode], + ) + }) + .collect::, _>>()?; + assert_eq!( + ExtractionSchema::new("product-card-v1", too_many_fields), + Err(ExtractionSchemaError::LimitExceeded) + ); + Ok(()) +} diff --git a/crates/originweave-evidence/tests/extraction_schema_error_contract.rs b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs new file mode 100644 index 000000000..b4897d90f --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_schema_error_contract.rs @@ -0,0 +1,48 @@ +use std::error::Error as _; + +use originweave_evidence::ExtractionSchemaError; + +fn assert_standard_error_contract() {} + +#[test] +fn extraction_schema_errors_implement_standard_error_contract() { + assert_standard_error_contract::(); + + for (error, message) in [ + ( + ExtractionSchemaError::InvalidIdentifier, + "invalid extraction schema or field identifier", + ), + ( + ExtractionSchemaError::LimitExceeded, + "extraction schema limit exceeded", + ), + ( + ExtractionSchemaError::InvalidCardinalityRequirement, + "extraction field required flag is incompatible with the declared cardinality", + ), + ( + ExtractionSchemaError::MissingSourceChannel, + "extraction field requires at least one source channel", + ), + ( + ExtractionSchemaError::DuplicateSourceChannel, + "extraction field contains a duplicate source channel", + ), + ( + ExtractionSchemaError::InvalidNormalizationRule, + "extraction normalization rule is incompatible with the field value type", + ), + ( + ExtractionSchemaError::MissingField, + "extraction schema requires at least one field", + ), + ( + ExtractionSchemaError::DuplicateField, + "extraction schema contains a duplicate field identifier", + ), + ] { + assert_eq!(error.to_string(), message); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-evidence/tests/extraction_source_channel_set.rs b/crates/originweave-evidence/tests/extraction_source_channel_set.rs new file mode 100644 index 000000000..1f5070e8a --- /dev/null +++ b/crates/originweave-evidence/tests/extraction_source_channel_set.rs @@ -0,0 +1,40 @@ +#![allow(clippy::expect_used)] + +use originweave_evidence::{ + ExtractionCardinality, ExtractionField, ExtractionSourceChannel, ExtractionValueType, +}; + +#[test] +fn equivalent_source_channel_sets_have_canonical_identity() { + let semantic_then_network = ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::NetworkResponse, + ], + ) + .expect("reviewed source set must be valid"); + let network_then_semantic = ExtractionField::new( + "product_name", + ExtractionValueType::Text, + ExtractionCardinality::One, + true, + &[ + ExtractionSourceChannel::NetworkResponse, + ExtractionSourceChannel::SemanticNode, + ], + ) + .expect("equivalent reviewed source set must be valid"); + + assert_eq!(semantic_then_network, network_then_semantic); + assert_eq!( + network_then_semantic.source_channels(), + &[ + ExtractionSourceChannel::SemanticNode, + ExtractionSourceChannel::NetworkResponse, + ] + ); +} diff --git a/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs new file mode 100644 index 000000000..6dbf8d713 --- /dev/null +++ b/crates/originweave-evidence/tests/sensitive_handle_access_binding.rs @@ -0,0 +1,114 @@ +use originweave_core::Origin; +use originweave_evidence::{ + SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, + SensitiveAccessOutcome, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, + SensitiveHandleLifecycleEvidenceInput, +}; + +type TestResult = Result<(), String>; + +fn access_evidence( + outcome: SensitiveAccessOutcome, + decision_epoch_seconds: u64, +) -> Result { + let destination = + Origin::parse("https://checkout.example.com").map_err(|error| format!("{error:?}"))?; + SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { + request_id: "request-42".to_owned(), + decision_id: "decision-42".to_owned(), + tenant_id: "tenant-7".to_owned(), + actor_id: "workload-browser-adapter".to_owned(), + task_id: "task-99".to_owned(), + field_ids: vec!["shipping_name".to_owned(), "shipping_address".to_owned()], + purpose_id: "fulfill-shipment".to_owned(), + destination, + classification: SensitiveAccessClass::PersonalData, + outcome, + policy_version: "sensitive-policy-v3".to_owned(), + approval_reference: None, + decision_epoch_seconds, + disclosure_epoch_seconds: None, + retention_deadline_epoch_seconds: Some(decision_epoch_seconds + 3_600), + }) + .map_err(|error| format!("{error:?}")) +} + +fn lifecycle_input( + access_evidence: SensitiveAccessEvidence, + issued_epoch_seconds: u64, +) -> SensitiveHandleLifecycleEvidenceInput { + SensitiveHandleLifecycleEvidenceInput { + access_evidence, + issued_epoch_seconds, + expires_epoch_seconds: issued_epoch_seconds + 300, + maximum_uses: 2, + resolution_count: 0, + revoked_epoch_seconds: None, + } +} + +#[test] +fn lifecycle_identity_retains_complete_opaque_handle_access_receipt() -> TestResult { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; + let evidence = + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access.clone(), 1_720_000_001)) + .map_err(|error| format!("{error:?}"))?; + + assert_eq!(evidence.access_evidence(), &access); + assert_eq!(evidence.request_id(), access.request_id()); + assert_eq!(evidence.decision_id(), access.decision_id()); + assert_eq!(evidence.access_evidence().tenant_id(), "tenant-7"); + assert_eq!(evidence.access_evidence().task_id(), "task-99"); + assert_eq!( + evidence.access_evidence().field_ids(), + ["shipping_name", "shipping_address"] + ); + assert_eq!( + evidence.access_evidence().destination().as_str(), + "https://checkout.example.com" + ); + Ok(()) +} + +#[test] +fn lifecycle_rejects_non_opaque_handle_access_decision() -> TestResult { + let denied = access_evidence(SensitiveAccessOutcome::DenyAccess, 1_720_000_000)?; + + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(denied, 1_720_000_001)), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} + +#[test] +fn lifecycle_rejects_issuance_before_policy_decision() -> TestResult { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_100)?; + + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(lifecycle_input(access, 1_720_000_099)), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} + +#[test] +fn lifecycle_expiry_respects_access_retention_deadline() -> TestResult { + let access = access_evidence(SensitiveAccessOutcome::OpaqueHandleOnly, 1_720_000_000)?; + let retention_deadline = access + .retention_deadline_epoch_seconds() + .ok_or_else(|| "fixture must carry a retention deadline".to_owned())?; + + let mut exact_deadline = lifecycle_input(access.clone(), 1_720_000_001); + exact_deadline.expires_epoch_seconds = retention_deadline; + SensitiveHandleLifecycleEvidence::try_from(exact_deadline) + .map_err(|error| format!("{error:?}"))?; + + let mut after_deadline = lifecycle_input(access, 1_720_000_001); + after_deadline.expires_epoch_seconds = retention_deadline + 1; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(after_deadline), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} diff --git a/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs new file mode 100644 index 000000000..95034cecc --- /dev/null +++ b/crates/originweave-evidence/tests/sensitive_handle_lifecycle_evidence.rs @@ -0,0 +1,142 @@ +use originweave_core::Origin; +use originweave_evidence::{ + SensitiveAccessClass, SensitiveAccessEvidence, SensitiveAccessEvidenceInput, + SensitiveAccessOutcome, SensitiveEvidenceError, SensitiveHandleLifecycleEvidence, + SensitiveHandleLifecycleEvidenceInput, +}; + +type TestResult = Result<(), String>; + +fn valid_access_evidence() -> Result { + let destination = + Origin::parse("https://shipping.example").map_err(|error| format!("{error:?}"))?; + SensitiveAccessEvidence::try_from(SensitiveAccessEvidenceInput { + request_id: "request-42".to_owned(), + decision_id: "decision-42".to_owned(), + tenant_id: "tenant-7".to_owned(), + actor_id: "workload-fulfillment".to_owned(), + task_id: "task-42".to_owned(), + field_ids: vec!["shipping.address".to_owned()], + purpose_id: "fulfill-shipment".to_owned(), + destination, + classification: SensitiveAccessClass::PersonalData, + outcome: SensitiveAccessOutcome::OpaqueHandleOnly, + policy_version: "sensitive-policy-v3".to_owned(), + approval_reference: None, + decision_epoch_seconds: 1_720_000_000, + disclosure_epoch_seconds: None, + retention_deadline_epoch_seconds: Some(1_720_003_600), + }) + .map_err(|error| format!("{error:?}")) +} + +fn valid_input() -> Result { + Ok(SensitiveHandleLifecycleEvidenceInput { + access_evidence: valid_access_evidence()?, + issued_epoch_seconds: 1_720_000_001, + expires_epoch_seconds: 1_720_000_301, + maximum_uses: 2, + resolution_count: 1, + revoked_epoch_seconds: None, + }) +} + +#[test] +fn records_bounded_handle_lifecycle_without_handle_or_secret_material() -> TestResult { + let evidence = SensitiveHandleLifecycleEvidence::try_from(valid_input()?) + .map_err(|error| format!("{error:?}"))?; + + assert_eq!(evidence.request_id(), "request-42"); + assert_eq!(evidence.decision_id(), "decision-42"); + assert_eq!(evidence.issued_epoch_seconds(), 1_720_000_001); + assert_eq!(evidence.expires_epoch_seconds(), 1_720_000_301); + assert_eq!(evidence.maximum_uses(), 2); + assert_eq!(evidence.resolution_count(), 1); + assert_eq!(evidence.revoked_epoch_seconds(), None); + assert!(!evidence.is_revoked()); + + let debug = format!("{evidence:?}"); + assert!(!debug.contains("opaque-handle-token-should-never-be-evidence")); + assert!(!debug.contains("raw-secret-should-never-be-evidence")); + Ok(()) +} + +#[test] +fn records_revocation_time_without_storing_revocation_payloads() -> TestResult { + let mut input = valid_input()?; + input.revoked_epoch_seconds = Some(1_720_000_120); + input.resolution_count = 2; + + let evidence = + SensitiveHandleLifecycleEvidence::try_from(input).map_err(|error| format!("{error:?}"))?; + + assert_eq!(evidence.revoked_epoch_seconds(), Some(1_720_000_120)); + assert!(evidence.is_revoked()); + assert_eq!(evidence.resolution_count(), evidence.maximum_uses()); + Ok(()) +} + +#[test] +fn records_revocation_at_exact_expiry_boundary() -> TestResult { + let mut input = valid_input()?; + input.revoked_epoch_seconds = Some(input.expires_epoch_seconds); + + let evidence = + SensitiveHandleLifecycleEvidence::try_from(input).map_err(|error| format!("{error:?}"))?; + + assert_eq!( + evidence.revoked_epoch_seconds(), + Some(evidence.expires_epoch_seconds()) + ); + assert!(evidence.is_revoked()); + Ok(()) +} + +#[test] +fn rejects_zero_or_non_increasing_handle_lifetime() -> TestResult { + for (issued, expires) in [ + (0, 1_720_000_301), + (1_720_000_301, 1_720_000_301), + (1_720_000_302, 1_720_000_301), + ] { + let mut input = valid_input()?; + input.issued_epoch_seconds = issued; + input.expires_epoch_seconds = expires; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(input), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + } + Ok(()) +} + +#[test] +fn rejects_zero_use_limit_or_resolution_count_above_limit() -> TestResult { + let mut zero_limit = valid_input()?; + zero_limit.maximum_uses = 0; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(zero_limit), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + + let mut overused = valid_input()?; + overused.resolution_count = overused.maximum_uses + 1; + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(overused), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + Ok(()) +} + +#[test] +fn rejects_revocation_before_issue_or_after_expiry() -> TestResult { + for revoked in [1_720_000_000, 1_720_000_302] { + let mut input = valid_input()?; + input.revoked_epoch_seconds = Some(revoked); + assert_eq!( + SensitiveHandleLifecycleEvidence::try_from(input), + Err(SensitiveEvidenceError::InvalidLifecycle) + ); + } + Ok(()) +} From 22449238068929127dc41176756776fc628f1880 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 12:18:52 +0900 Subject: [PATCH 558/570] docs(gaps): refresh commercial baseline to live state --- docs/product-technical-gap-baseline.md | 365 +++--------------- ...test_gap_snapshot_inventory_consistency.py | 65 ++-- tests/test_product_completion_gap_contract.py | 144 +++---- 3 files changed, 128 insertions(+), 446 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 8a702c75f..d6acbb35e 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,346 +1,91 @@ # Product and Technical Gap Baseline -This is a dated delivery baseline, not a substitute for the PRD, TRD, roadmap, architecture decisions, or live GitHub state. It keeps buyer-visible gaps, current issues, active pull-request evidence, and commercial completion tracks in one discoverable place. Protected `main` is the implementation boundary: code in an open pull request is not shipped behavior. +This file is the current delivery baseline for OriginWeave. It records buyer-visible gaps and exact repository evidence; it does not replace the PRD, TRD, architecture, ADRs, threat model, test strategy, or live GitHub state. Protected `main` is the shipped implementation boundary. Open PRs, successful predecessor checks, synthetic mergeability, and command acknowledgements are not shipped behavior. -## Observed snapshot: 2026-08-26 +## Observed snapshot: 2026-09-06 ### Protected-main truth -- Protected `main` is at `b05d5acca82b9d916ada2c8e82f59f92a89817e1` for this snapshot. Since the 2026-08-24 observation (`0841d2ab`), protected `main` absorbed #196 (dated gap baseline publication), #216 (RFC 3986 evidence-path syntax enforcement), #194 (branch-coverage nightly and toolchain tracking refresh), #168 (typed MCP stateless tool-routing foundations), and #151 (exact crash-root termination before crash credit). -- Phase 0 remains complete as a reusable safety-kernel foundation: typed policy contracts, destination classification, direct TCP peer verification, TLS service identity, evidence bounds, resource mitigation, document-node authority, and protected-main tests. -- Phase 1 is **in progress**, not shipped. The first real Chromium vertical slice still needs the active WebDriver BiDi transport stack to reach protected `main`, then compose isolated Chromium launch, session/context identity, semantic observation, typed action authorization, native browser input, post-condition proof, evidence, cancellation, crash recovery, and profile/process teardown. -- HTTP/1.1 bounds, downloads/MIME, proxy/PAC consumption, full browser-network integration, the sensitive-data broker runtime, durable WARC/PROV capture, persistent task/API surfaces, signed cross-platform distribution, enterprise administration, and release-grade buyer acceptance remain open. -- Active pull requests remain evidence, not shipped behavior. Successful checks on a feature or stacked branch do not prove that protected `main` contains the capability or that a child can merge before its prerequisite. +- Protected `main` is exact `87c4daa1830bac5a5228b6036752ad5633232085`. GitHub reports the commit signature as verified/valid. +- The repository currently has **125 open pull requests: 12 non-draft and 113 draft**. +- The repository currently has **13 open non-PR issues**. +- The GitHub Releases API currently returns an empty collection: **0 GitHub Releases**. No release-ready claim is valid until a protected exact head is integrated and an immutable release artifact, SBOM, provenance, rollback evidence, tag/package, and release are all verified. +- Protected-main code and tests remain authority for shipped behavior. A feature branch can be useful evidence without being a production capability. -### Open pull requests +### Foundation and stack integrity -The live repository contained **126 open pull requests: 54 non-draft and 72 draft** when this snapshot re-paginated the complete open inventory. Compared with the prior **2026-08-24 158-PR snapshot**, the current inventory is 32 PRs smaller. Intervening queue consolidation includes #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 being merged into their immediate stacked prerequisites, while PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review. Those transitions are queue consolidation, not protected-main delivery; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`, with 13 open issues and no releases or tags. The volume and stack depth remain themselves a product-delivery risk: review, exact-head checks, dependency order, and integration truth can drift faster than a buyer-visible vertical slice reaches protected `main`. +The active WebDriver BiDi stack inherited a historical whole-tree replacement, `5c111d0db6c363f9d1786c21cc01c5c7398007bd` (`fix(stack): restore opening-write prerequisite tree`), that restored its transport prerequisite but also removed unrelated valid product/source/test/documentation assets. That deletion is a repair finding rather than grounds to close dependent PRs. -#### 2026-08-26 maintenance-loop record +PR #195 is the earliest active owner point currently repairing that foundation. Its exact head is `89708cf5e474f7701513b84a1356a8ce1699bef5` on retained prerequisite #193 `6922dd98779e8f8aad132a3b1f563d7ba6e6d070`. Two ordinary forward commits restore protected product contracts while preserving later browser work: -The interactive maintenance loop performed the following verified state changes on exact heads; none of them is protected-main behavior until merged: +- `29dd314501299a3ad8276e5d73189591ff6327a0` restores the BAP workspace member, MCP and release-acceptance contracts/tests, destination freshness/revalidation, policy MCP binding, resource error contracts, TLS revocation/trust, the Agent Task fixture, and this product/technical gap baseline without replacing the modular WebDriver BiDi core. +- `89708cf5e474f7701513b84a1356a8ce1699bef5` restores extraction schema, sensitive-handle lifecycle, RFC 3986 evidence-path admission, and their tests while retaining `BrowserProtocolValidationEvidence` and its browser-protocol regression. -| Action | Exact evidence | -|---|---| -| Supersession closure | #153 closed with replacement evidence: base-stack tip (`4da223ac`) already implements `_terminate_owned_process_bounded` exit-race tolerance that supersedes the branch delta | -| Conflict reconciliation | Merge commits pushed to #37 (`27f6acd6`, ci.yml aligned to reviewed `nightly-2026-08-18` pin), #149 (`7852a540` + rustfmt fix `54f96008`), #152 (`65b0c705`), #173 (`ecc9574a`), #175 (`765c88f6`, keeps `crate_root.rs` naming) | -| Governance remediation (#212) | #43 reconciled with main in `04e262d5`; the `chrome_sandbox` workflow mutation was first removed, then restored under recorded independent authorization (issue #212 option (b)) because the PR's own contract test fails closed without it; fresh exact-head checks re-ran on the restored head | -| Security finding fix (#124) | Strix vuln-0001 (Unicode homoglyph path confusion, MEDIUM) remediated in `30cc458b`: audited workflow paths now restricted to a canonical ASCII alphabet with homoglyph/fraction-slash/fullwidth regression contract tests; CHANGELOG updated | -| Fail-closed provider re-dispatch | ~21 failed Strix required-check runs re-dispatched on unchanged exact heads; completed reruns returned success on #46, #48, #156, #157, #159, #218, and #219 heads at snapshot time; cancellations only where newer heads superseded the run | -| Current-head review re-dispatch | Central merge-scheduler dispatches sent for #47, #62, #63, #65, #74, #166, #173, #175, and #220 because their stale `CHANGES_REQUESTED` verdicts cited coverage-evidence results that are green on the same heads today | +The child PR #242 still points at the pre-repair #195 generation and is currently non-mergeable after the base branch advanced. Descendants must therefore adopt the repaired foundation content-aware and non-destructively; preserving an old child tree in a topology-only merge would reintroduce the deleted product assets. Each reconstructed exact head needs fresh checks. No predecessor GREEN transfers. -#### Organization review-pipeline congestion record +### Browser sandbox and realistic Chromium acceptance -Between 2026-08-26T02:44Z and 2026-08-26T03:35Z the organization-wide Actions queue exhibited a systemic backlog: scheduler, OpenCode-review-dispatch, Noema, and Strix runs across `.github`, `naruon`, `pg-erd-cloud`, and OriginWeave sat `queued`/`pending` while only single-digit runs were `in_progress`. This delays every current-head AI review and therefore every ruleset-gated merge. It is an infrastructure-capacity signal, not a code defect, and it does not authorize merging without current-head review evidence. +PR #148 is exact `0135984f1bc1f68d89d7777f49c4999474105a12`. Its repository CI `33990522263` is terminal success with exact 100% reported production coverage (415 functions, 3,555 lines, 4,444 regions, 476 branches). Its real Manifest V3 Compatibility run `33990522248`, job `101371812631`, is terminal failure on pinned Chrome `150.0.7871.129` after all inherited `--no-sandbox` launch overrides were removed. Artifact `9977680352` reports 0/3 for ordinary MV3, ordinary Agent Task, forced-close Agent Task, and browser-crash Agent Task; the crash lane localizes to `failure_stage=session_create`, `failure_type=RuntimeError`, `reason_code=runtime_error`. Cleanup completion is not browser success. -Representative active workstreams at this snapshot were: +Issue #212 is the canonical workflow-owner boundary for the missing sandbox-helper integration. PR #43 previously proved that root-owned mode-`4755` `chrome_sandbox` plus `CHROME_DEVEL_SANDBOX` can run the same Chrome generation sandboxed on that leaf generation, but its GREEN does not transfer to #148 or to the current protected workflow. The authorized owner must reconstruct the validated helper mechanics against the current protected MV3 workflow, preserve harden-runner/egress, immutable pins, Draft/closed lifecycle and evidence retention, then consumers must adopt it non-destructively and regenerate exact-head Linux browser evidence. Restoring `--no-sandbox`, reducing trials, or treating cleanup as success is not an acceptable repair. -| Workstream | Representative active PR evidence | Delivery boundary | -|---|---|---| -| Product baseline | (merged: #196 on 2026-08-24) | Baseline publication reached protected `main`; this document is its successor snapshot | -| Presentation identity | #229 at `585a7d5545b13f18d76f79100ff4d47ac423e861` onto `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | Ready/non-draft local privacy kernel; all observed exact-head checks except Strix passed, but the PR remains blocked and review-required, and no Chromium adapter or protected-main shipment is claimed | -| Enterprise approval authority | #220 | Ready/non-draft bounded maker-checker approval lifecycle on the exact `ApprovalScope`; all current-head checks green at snapshot, awaiting current-head review evidence | -| Release artifact identity | #218 and #219 | Ready/non-draft fail-closed benchmark release decision and canonical release manifest binding; Strix provider-failure reruns completed green on both heads | -| Schema-bound extraction and BAP lifecycle | #209 and #208 | Ready/non-draft schema-bound extraction contract and resumable task-lifecycle kernel; #209 Strix rerun green, #208 rerun re-dispatched after a further provider failure | -| WebDriver BiDi transport | #188 through #205 | Active stack whose top #205 merged into its prerequisite branch, not protected `main`; it exercises framed `locateNodes` exchange over a bounded WebSocket opening path, but authenticated browser-process provenance, semantic task execution, and protected-main shipment remain unproven | -| MCP adapter | (#168 merged) and #170 | Typed MCP routing foundations are protected-main behavior since 2026-08-24; conservative `tools/list` cache metadata remains active-PR evidence with a Strix rerun in flight | -| Workflow-registry audit | #124 | Real Strix finding vuln-0001 (Unicode homoglyph path confusion, MEDIUM) remediated on head `30cc458b` with regression contract tests; fresh exact-head checks and review re-running | -| Controlled Chromium and recovery | #65, #70-#73, #100, #105, #142-#152 and descendants | Real pinned-browser fixture, semantic location, resource, crash, and teardown evidence exists on active stacks; evidence does not transfer across heads or prerequisites | -| Durable WARC/PROV evidence | #210, #217 | Bounded WARC resource records and PROV JSON-LD binding are draft active-PR foundations; durable ownership, replay, retention/deletion, and browser side-effect reconciliation remain open | -| Manifest V3 and native messaging | #27, #43 governance remediation, and the extension/native-host stack including #154 and #169 | Compatibility and Agent-authority isolation remain incomplete until exact release artifacts and platform matrices are proven; #43's sandbox workflow mutation is now owner-authorized under issue #212 option (b) | -| Sensitive-data and model route policy | #10 and its active policy stacks | Deterministic policy values exist, but trusted broker execution, retention/deletion, runtime isolation, and auditable product workflows remain open | -| VPN/profile intent | #149 | Bounded WireGuard/IKEv2 profile authority reconciled with main (`54f96008`); it does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof | - -PR #205 head `f427aa69151987d7e3369bd96d5739ea38d0f7ad` merged as `6c5ef5e2079d54c617183ecfa757e406f48f0aea` into stacked prerequisite branch `feat/webdriver-bidi-websocket-frame-transport` at base `c1bc7e78f3a9debf4f517fb6b5f11dd67be4ad92`. Its successful exact-head checks are stacked-branch integration evidence only; protected `main` remains `b05d5acca82b9d916ada2c8e82f59f92a89817e1`. - -#### Current exact-head active PR evidence - -The following newest slices were re-fetched from GitHub for this snapshot. Their exact base/head pairs are recorded so later checks, reviews, and restacks cannot be confused with predecessor evidence: - -| PR | State | Exact base head | Exact head | -|---|---|---|---| -| #220 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e0740a6f3a41067a4460249378e0266815018a74` | -| #219 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `3e34a54ae279686a28309d59b8b3b9bfbd283a80` | -| #218 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `911ea33d8a5aca7673307bb6fdcad4b450f5c111` | -| #209 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `b35d739017aa5d361b605be48045be50b5a35f6f` | -| #208 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `e41d3be4c290c4e434aac33d777e511dfb94e03d` | -| #124 | Ready | `b05d5acca82b9d916ada2c8e82f59f92a89817e1` | `296ad25bb541023dbc869ae07ae1d853820f83a4` | - -These rows are delivery evidence only. None has counted independent approval in the current collaborator inventory, and predecessor rows from earlier snapshots are retained below as regression anchors that must never be promoted to current-head evidence. - -#### Regression-anchor exact-head evidence: superseded 2026-08-24 rows - -The following rows were current on 2026-08-24 and are retained only as regression anchors; every listed head has since been superseded or merged and must never be promoted to current-head evidence: - -| PR | State | Exact base head | Exact head | -|---|---|---|---| -| #222 | Draft | `56fcfa56525e4f2e980e0ee05b6776d621bcddc5` | `1e2ce3d4071a1a75ee891bdcd71c506b3b50d4bc` | -| #221 | Draft | `8145d40f1b028a8f4dc7e7da47ac89bb9e5bb2c7` | `6f339df1e5b3ddb265f4ddd7b262d4de1e0b5e1f` | -| #220 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `ed4cab16cf88c76ce1c145a22d0a274ef2d57263` | -| #219 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `8145d40f1b028a8f4dc7e7da47ac89bb9e5bb2c7` | -| #218 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `49e98fba6974219b3bb0336c822b12667f1e1c03` | -| #217 | Draft | `529d11a3571f6b1834b9baa49ef67eb08f043978` | `56fcfa56525e4f2e980e0ee05b6776d621bcddc5` | -| #216 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `75130851a0f7ce528a7a36382eb026ac7942a0aa` | -| #214 | Draft | `40d642d5470a7753b8211907c190367f742f2f12` | `f79999681866ecf0e5fe17d895170f3f6cae7361` | -| #211 | Draft | `85cc477688246900697f4cfb91c0c8f1f692934a` | `40d642d5470a7753b8211907c190367f742f2f12` | -| #210 | Draft | `c38b9665774d6b3754e572bed527737b5e179833` | `529d11a3571f6b1834b9baa49ef67eb08f043978` | -| #209 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `c38b9665774d6b3754e572bed527737b5e179833` | -| #208 | Ready | `0841d2ab3d8b5e60a03c0a8e818cf438e2716829` | `85cc477688246900697f4cfb91c0c8f1f692934a` | - -The stack topology shows #209 → #210 → #217 → #222 (WARC/PROV chain), #208 → #211 → #214 (BAP chain), #218 → #221 → #220 (release/enterprise chain) at this snapshot. Every row above remains active-PR evidence; none is protected-main behavior. - -### Required-check provider failure record - -On 2026-08-23 the required Strix security scan failed closed on exact heads of #220 (`ed4cab16…`), #218 (`49e98fba…`), and #208 (`85cc4776…`) because its LLM provider/backend was unavailable (rate limit, token cap, connection, warm-up, or model-behavior failure); no vulnerability report artifact was produced, so the workflow correctly refused to convert an incomplete scan into passing security evidence. Failed jobs were re-dispatched on the unchanged exact heads on 2026-08-24 and again on 2026-08-26. This is a provider-infrastructure failure record, not a weakening of the fail-closed gate or a substitute for a completed authoritative scan. - -On 2026-08-26 rerun outcomes were verified per run: completed reruns returned `success` on the heads of #46, #48, #156, #157, #159, #218, and #219; several earlier runs for #37, #43, and #149 were cancelled only because conflict-reconciliation pushes created newer heads with fresh scans; remaining reruns were still in flight at snapshot time. One rerun (#124) produced a real MEDIUM finding (vuln-0001) instead of provider noise; that finding was remediated on the branch head rather than suppressed, preserving the fail-closed contract. - -#### #195/#198 WebDriver BiDi opening path status +### CI, review, and evidence control plane -Phase 1 is **in progress**, not shipped. #195 and #198 provide bounded WebSocket opening-path evidence on active branches; framed BiDi commands, authenticated browser-process provenance, semantic task execution, and protected-main integration remain open. +Issue #279 remains the protected-main owner for exact-head documentation verification and the Ready-transition execution gap. Its current record shows workflow-free classifier PR #287 succeeding in native CI/Security/Semgrep while required CodeQL fails only after current-head scan dispatch at the central verdict handoff. That repeated CodeQL dispatch-to-verdict defect is owned by `ContextualWisdomLab/.github#712`; leaf branches must not duplicate CodeQL, weaken required checks, or convert queued/skipped/provider-incomplete evidence into GREEN. -#### #149 VPN/profile intent status +Protected review/ruleset requirements remain independent from tests. Passing automation is not approval. Stale review state after a push is not current approval, and a Draft, conflicted, or stack-incomplete PR is not merge-ready merely because one repository workflow passed. -PR #149 is a ready (non-draft) pull request whose conflict reconciliation and rustfmt correction landed on head `54f96008` on 2026-08-26; it still only describes bounded WireGuard/IKEv2 profile authority and does not create a tunnel, route, DNS state, authenticated gateway, or connectivity proof. +### Product and buyer gaps that remain open -The current queue must be processed in dependency order. A green child branch cannot substitute for current checks and review on its prerequisite, synthetic merge, or eventual protected-main commit. PRs that only duplicate, supersede, or preserve stale branch topology should be closed with explicit replacement evidence rather than retained indefinitely; this loop exercised that policy by closing superseded #153 with replacement evidence. +The following capabilities are not treated as protected-main commercial completion merely because foundations or active PRs exist: -### Review and merge authority - -The active `CWL Central required workflows` ruleset (re-fetched for this snapshot) requires one approving review, resolved review threads, no last-push approval requirement, `merge`/`squash` merge methods, and seven configured required workflows (`close-empty-pr`, `opencode-review`, `pr-review-merge-scheduler`, `security-scan`, `strix`, `sast-semgrep`, `noema-review`). The current collaborator inventory contains only `seonghobae` with administration and push permissions, creating a **reviewer-provisioning gap** for counted non-author approval. - -This gap does not authorize self-approval, stale-head merges, administrative bypass, or weaker checks. Because the current GitHub ruleset independently requires a counted approval, the solo-maintainer hold does not satisfy the live merge gate: an eligible non-author collaborator must submit a formal `APPROVED` review on the current head. Until that reviewer-provisioning gap is repaired, protected-main merges stop even when exact-head checks, security gates, complete coverage, rustdoc/Clippy, threads, and AI-review evidence are otherwise complete. Before any merge decision, re-fetch the exact ruleset, collaborators, PR head/base, reviews, unresolved threads, and required checks; do not assume this dated observation remains current. - -### Open issues and operational signals - -| Issue | Current gap or signal | -|---|---| -| #28 | First real Chromium Agent Task vertical slice; highest immediate Phase 1 buyer-visible gap | -| #27 | Complete Manifest V3 compatibility and extension-authority isolation matrix | -| #9 | Bounded HTTP/1.1 semantics over the authenticated TLS stream | -| #10 | Purpose-bound operational PII disclosure and trusted broker/storage lifecycle | -| #123 | Fleet incident: disable orphaned TLS, HTTP, and one-shot workflow identities | -| #187 | Manual-authority review of the coverage-diagnostics workflow delta | -| #212 | Governance: remove or independently authorize the PR #43 MV3 workflow mutation — **option (b) executed 2026-08-26** with owner-directed authorization recorded on the issue and the mutation restored on the reconciled branch; re-evaluate if the authorization record is contested | -| #215 | Governance: restore an enforceable protected-main policy that does not create a routine admin bypass | -| #199 | Schema-bound extraction with durable WARC/PROV replay, retention, deletion, and offline verification | -| #200 | Stable BAP/MCP runtime API with authenticated, idempotent, cancellable, resumable task lifecycle | -| #201 | Signed cross-platform Chromium distribution, installer/updater, patch SLA, rollback, SBOM, and provenance | -| #202 | Enterprise control and experience plane: operator UI, Keyverse-compatible identity, tenancy, approval, audit, SLO, Figma, and Storybook | -| #203 | Release-grade web-agent benchmark and commercial acceptance gate bound to exact signed artifacts | - -Issue #206 (harden-runner custom detection initialization failure) was closed after its remediation landed on protected `main` between snapshots. - -The five newly separated product-completion tracks are **durable WARC/PROV replay**, **stable BAP/MCP runtime API**, **signed cross-platform Chromium distribution**, **enterprise control and experience plane**, and the **commercial acceptance gate**. They are separate issues because each has a distinct authority, data, release, and buyer-acceptance boundary. - -The hourly product-development loop is operational infrastructure, not proof that a browser product, issue, pull request, or release meets buyer acceptance. - -## Buyer-visible and technical gap matrix - -| Priority | Buyer-visible outcome | Protected-main status | Completion issue and acceptance evidence | -|---|---|---|---| -| P0 | A bounded task observes a real Chromium page, performs one typed action, verifies the post-condition, and emits provenance | **Open / Phase 1** | #28; repeated real Chromium E2E with isolated context, exact session/node authority, typed dispatch, post-condition, crash cleanup, and protected-main checks | -| P0 | Navigation consumes approved origin, resolution, route, TCP peer, TLS identity, bounded HTTP, redirect, MIME, and download policy | **Partial foundation** | #9 plus #28; real browser-network adapter proves the governed path is consumed end to end | -| P1 | Existing Chromium extensions remain compatible while Agent authority stays separate | **Partial active-PR evidence** | #27; exact supported-build/platform compatibility matrix, managed allow-list, native-host isolation, repeatability, and release binding | -| P1 | Authorized work can use necessary PII without ambient exposure | **Policy foundation; runtime open** | #10; opaque broker, exact field/purpose/destination/model policy, atomic use/revocation, retention/deletion, and value-free telemetry | -| P1 | Every released structured field is traceable to replayable source evidence | **Foundations only** | #199; durable WARC/PROV replay, integrity, retention, deletion, offline verification, extraction precision/recall, and 100% provenance completeness | -| P1 | External Agents integrate through a stable, authenticated product contract | **Partial active-PR MCP primitives** | #200; BAP 1.0, MCP 2026-07-28 adapter, idempotency, task cancellation/resume, checkpoint/reconciliation, and SDK conformance | -| P1 | Buyers can install, update, verify, and roll back a supported product | **Not shipped** | #201; signed Windows/macOS/Linux/headless artifacts, Chromium revision manifest, updater security, patch SLA, SBOM, SLSA provenance, and recovery | -| P1 | Enterprise teams can provision, approve, audit, operate, and recover the service | **Not shipped** | #202; Keyverse-compatible OIDC/SCIM, tenant isolation, policy/approval/evidence UI, SLO/incident controls, data residency, CSAP/SOC 2 evidence mapping, WCAG 2.2, Figma File ID, and Storybook | -| P0 | A release has reproducible proof of usefulness, safety, evidence completeness, and recovery | **No product-wide release gate** | #203; deterministic, compatibility, adversarial, recovery, and enterprise suites with statistical reporting and an exact-artifact commercial acceptance gate | -| P0 | Valid changes reach protected `main` without authority improvisation or unbounded stack growth | **Blocked / high integration debt** | Shrink the 126-PR queue in dependency order, provision legitimate review authority, require exact-current evidence, and close duplicates/superseded branches | +| Track | Current boundary | Completion evidence required | +|---|---|---| +| Governed browser vertical slice | WebDriver BiDi contracts and transport work are active; current stack requires foundation repair/restack | Real pinned Chromium session/navigation/semantic observation/policy-authorized interaction/post-condition/evidence/cleanup GREEN on the same exact head, then protected integration | +| Chromium sandbox | #148 fails closed at session creation without sandbox bypass; #212 owns workflow integration | Current-generation least-privilege helper adoption plus exact-head sandboxed Linux replay | +| Evidence/provenance | Redacted network/provenance, extraction schema, sensitive lifecycle, and browser-protocol validation contracts exist | Durable replay/retention/deletion and buyer-facing evidence lifecycle proven end-to-end | +| MCP/agent boundary | Typed stateless MCP/core authority contracts exist; MCP remains an adapter | Released API/adapter behavior that cannot become policy authority or bypass browser post-condition verification | +| Persistent task/API surface | Foundations exist | Tenant-scoped persistence, recovery, idempotency, operability, and API acceptance on protected code | +| Enterprise administration | Governance primitives exist | Buyer-visible policy/approval/audit administration with purpose-bound sensitive-data handling and accessibility verification | +| Distribution and release | No GitHub Release exists | Signed cross-platform artifacts, SBOM/provenance, reproducibility, rollback, package/tag and immutable release verification | +| CI evidence throughput | Exact-head verification exists but central verdict/queue issues remain | Reliable exact-head required workflows without gate weakening, skipped-result promotion, or stale evidence transfer | -## Commercial completion definition +### Bounded-context and ownership constraints -OriginWeave is not complete merely because every low-level primitive exists in some open branch. A release candidate is commercially complete only when all of the following are true for the declared support profile: +OriginWeave owns governed browser-domain truth: Browser Session, Navigation, Observation, Interaction Policy integration, Evidence, Extension/native-host boundary, and browser adapters. WebDriver BiDi, CDP and MCP are adapters, not policy authority. Wardnet, EgressWeave, Keyverse, contextual-orchestrator and Context Fabric remain canonical owners of their own domains; OriginWeave consumes only released/versioned contracts or ACLs and must not copy their source, use cross-service SQL, or depend on mutable sibling heads. -1. #9, #10, #27, and #28 are integrated on protected `main` as a complete browser/network/action/evidence chain. -2. #199 provides replayable, retention-governed evidence for every released structured result. -3. #200 exposes a stable authenticated runtime API and task lifecycle without raw Chromium authority leakage. -4. #201 produces signed, updateable, rollback-capable release artifacts bound to Chromium, SBOM, and provenance. -5. #202 supplies tenant-safe enterprise administration, approvals, audit, SLOs, incident recovery, accessible Figma/Storybook-backed UX, and control evidence. -6. #203 accepts the exact signed artifacts through a reproducible benchmark; missing or inconclusive evidence cannot be promoted to success. -7. Production function, line, region, and branch coverage and public API documentation remain exactly complete for OriginWeave-owned code. -8. CHANGELOG, version, supported-platform matrix, security policy, runbooks, licensing, release notes, upgrade/rollback guidance, and procurement evidence match the exact release. -9. No required check, browser/platform lane, security case, benchmark case, or independent review is skipped, stale, inherited, or represented by status-only evidence. -10. The open PR queue is reduced to bounded active work rather than being the only place where the product exists. +Deterministic browser policy/security decisions remain deterministic. Model-backed workflows must not substitute LLM judgement for browser authority. Command ACK is never sufficient for task success; the expected post-condition and evidence must be observed. -## Next executable queue +### Current repair order -1. Drain the merge gate in dependency order: for every ready root PR whose current head is check-green with resolved threads, obtain the current ruleset's counted `APPROVED` review from an eligible non-author collaborator; OpenCode approval or skip evidence does not substitute for that GitHub review. If no eligible approver exists, record the reviewer-provisioning gap and do not merge. Root candidates include #37, #40, #43, #45–#48, #51, #62–#65, #74, #82, #124, #149, #152, #156–#166, #170, #173, #175, #208, #209, #218, and #219 as their re-dispatched checks land. Treat dependent children separately: only after a predecessor reaches protected `main`, retarget and independently revalidate its immediate child; preserve orders such as #218 → #221 → #220 rather than treating #208–#220 as a flat merge range. -2. Keep the organization review pipeline healthy: monitor the central Actions backlog recorded above; if OpenCode reviews stop landing on OriginWeave heads while the queue is idle, repair `ContextualWisdomLab/.github` dispatch/concurrency configuration rather than weakening any gate. -3. Finish the #9/#28 browser-network and Chromium vertical slice, including the #181–#205 WebSocket opening path and framed BiDi command/response stack, then semantic observation, policy, action, post-condition, and recovery boundaries on protected `main`. -4. Finish #27 and #10 as separate security tracks; neither should be hidden inside the first browser PR. -5. Implement #199, then #200, so durable evidence and stable task authority precede broad enterprise integrations. -6. Implement #201 before making release/support claims; exact CI browser evidence must be bound to the actual signed artifact. -7. Design #202 in Figma, record the Figma File ID in the ADR, implement reusable design tokens and Storybook components, then add identity/tenant/approval/audit/operations integration. -8. Make #203 the final release gate across the exact signed distribution, not a source branch or model narrative. -9. Only after the commercial acceptance gate passes, increment the version, finalize CHANGELOG/release notes, publish signed artifacts, and verify upgrade/rollback from the prior supported release. +1. Finish #195 exact-head repository verification and repair any new RED at that exact head. +2. Reconcile the remaining inherited documentation differences content-aware; do not overwrite later WebDriver deltas with an older whole tree. +3. Reconstruct #242 and descendants from the repaired #195 foundation using ordinary forward/non-force adoption, then regenerate exact-head checks on every claimed integration point. +4. Complete #212's authorized current-generation Chromium sandbox-helper integration and rerun realistic pinned-Chromium evidence on the exact consumer head. +5. Resolve central required-verdict failures through their canonical owner (`ContextualWisdomLab/.github#712`) rather than leaf duplication or gate weakening. +6. Integrate dependency-first through normal protected-branch review/ruleset gates. +7. Produce and verify the first immutable OriginWeave release with signed artifacts, SBOM, provenance, reproducibility and rollback evidence. ## Evidence commands -The volatile counts above are reproducible by paginating the complete open-PR inventory, flattening every page, and then inspecting each PR's exact head, checks, reviews, and review threads: +The snapshot is reproducible from GitHub without treating local branch state as authority. Paginate list endpoints before deriving counts or per-PR evidence. ```bash set -euo pipefail -EVIDENCE_DIR="$(mktemp -d /tmp/originweave-evidence.XXXXXX)" -printf 'Evidence directory: %s\n' "$EVIDENCE_DIR" >&2 - -gh api --paginate --slurp 'repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100' \ - > "$EVIDENCE_DIR/open-pr-pages.json" -jq '[.[][]]' "$EVIDENCE_DIR/open-pr-pages.json" \ - > "$EVIDENCE_DIR/open-prs.json" -jq '{ - open_pull_requests: length, - non_draft: (map(select(.draft == false)) | length), - draft: (map(select(.draft == true)) | length) -}' "$EVIDENCE_DIR/open-prs.json" +repo=ContextualWisdomLab/OriginWeave -gh api 'repos/ContextualWisdomLab/OriginWeave/branches/main' \ - > "$EVIDENCE_DIR/main-branch.json" -gh api --paginate --slurp \ - 'repos/ContextualWisdomLab/OriginWeave/rules/branches/main?per_page=100' \ - > "$EVIDENCE_DIR/main-branch-rule-pages.json" -jq '[.[][]]' "$EVIDENCE_DIR/main-branch-rule-pages.json" \ - > "$EVIDENCE_DIR/main-branch-rules.json" -gh api --paginate --slurp \ - 'repos/ContextualWisdomLab/OriginWeave/collaborators?affiliation=all&per_page=100' \ - > "$EVIDENCE_DIR/collaborator-pages.json" -jq '[.[][]]' "$EVIDENCE_DIR/collaborator-pages.json" \ - > "$EVIDENCE_DIR/collaborators.json" +gh api "repos/$repo/branches/main" +gh api --paginate "repos/$repo/pulls?state=open&per_page=100" --slurp +gh api --paginate "repos/$repo/issues?state=open&per_page=100" --slurp +gh api "repos/$repo/releases?per_page=100" -jq -r '.[].number' "$EVIDENCE_DIR/open-prs.json" | while read -r PR; do - STABLE_HEAD=false - for ATTEMPT in 1 2 3; do - VERDICT_PATH="$EVIDENCE_DIR/pr-${PR}-merge-verdict.json" - VERDICT_TMP="$EVIDENCE_DIR/pr-${PR}-merge-verdict.json.tmp" - rm -f "$VERDICT_PATH" "$VERDICT_TMP" "$EVIDENCE_DIR/pr-${PR}-rechecked.json" - PR_JSON="$EVIDENCE_DIR/pr-${PR}.json" - gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" > "$PR_JSON" - HEAD_SHA=$(jq -r '.head.sha' "$PR_JSON") - BASE_SHA=$(jq -r '.base.sha' "$PR_JSON") +gh api "repos/$repo/pulls/195" +gh api "repos/$repo/commits/89708cf5e474f7701513b84a1356a8ce1699bef5/check-runs?per_page=100" +gh api "repos/$repo/actions/runs?head_sha=89708cf5e474f7701513b84a1356a8ce1699bef5&per_page=100" - gh api --paginate --slurp \ - "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100" \ - > "$EVIDENCE_DIR/pr-${PR}-check-runs.json" - gh api --paginate --slurp \ - "repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/statuses?per_page=100" \ - > "$EVIDENCE_DIR/pr-${PR}-statuses.json" - gh api --paginate --slurp \ - "repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100" \ - > "$EVIDENCE_DIR/pr-${PR}-reviews.json" - gh api --paginate --slurp \ - "repos/ContextualWisdomLab/OriginWeave/actions/runs?head_sha=$HEAD_SHA&per_page=100" \ - > "$EVIDENCE_DIR/pr-${PR}-workflow-runs.json" - gh api graphql --paginate --slurp \ - -F owner=ContextualWisdomLab \ - -F name=OriginWeave \ - -F number="$PR" \ - -f query=' -query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - reviewThreads(first: 100, after: $endCursor) { - nodes { id isResolved isOutdated } - pageInfo { hasNextPage endCursor } - } - } - } -}' > "$EVIDENCE_DIR/pr-${PR}-review-threads.json" - - jq -n \ - --arg head "$HEAD_SHA" \ - --slurpfile pr "$PR_JSON" \ - --slurpfile checks "$EVIDENCE_DIR/pr-${PR}-check-runs.json" \ - --slurpfile statuses "$EVIDENCE_DIR/pr-${PR}-statuses.json" \ - --slurpfile reviews "$EVIDENCE_DIR/pr-${PR}-reviews.json" \ - --slurpfile workflow_runs "$EVIDENCE_DIR/pr-${PR}-workflow-runs.json" \ - --slurpfile rules "$EVIDENCE_DIR/main-branch-rules.json" \ - --slurpfile collaborators "$EVIDENCE_DIR/collaborators.json" \ - --slurpfile threads "$EVIDENCE_DIR/pr-${PR}-review-threads.json" \ - --arg base "$BASE_SHA" \ - '( - [ - $rules[][]? - | select(.type == "pull_request") - | .parameters - ] | first // {} - ) as $pull_request_parameters - | ( - [ - $reviews[][][]? - | {reviewer: .user.login, state, submitted_at, commit_id} - | select(.submitted_at != null) - | select(.reviewer != $pr[0].user.login) - | select(.reviewer as $reviewer | - any($collaborators[][]?; - .login == $reviewer and - (.permissions.push == true or - .permissions.maintain == true or - .permissions.admin == true))) - ] - | group_by(.reviewer) - | map(sort_by(.submitted_at) | last) - | map(select(.state == "APPROVED" and .commit_id == $head)) - ) as $current_approvals - | ($pull_request_parameters.required_approving_review_count // 0) as $required_review_count - | ($pull_request_parameters.require_last_push_approval // false) as $require_last_push_approval - | { - head_sha: $head, - base_sha: $base, - required_status_checks: { - check_runs: [$checks[][].check_runs[]?], - legacy_statuses: [$statuses[][][]?] - }, - workflow_runs: [$workflow_runs[][].workflow_runs[]?], - counted_approvals: ($current_approvals | length), - required_approving_review_count: $required_review_count, - require_last_push_approval: $require_last_push_approval, - last_push_approval_authority: ( - if $require_last_push_approval == true - then "github_rule_evaluation_required" - else "not_required" - end - ), - approval_gate_satisfied: ( - if $pull_request_parameters.require_last_push_approval == true then false - else (($current_approvals | length) >= $required_review_count) - end - ), - required_workflows: [ - $rules[][]? - | select(.type == "workflows") - | .parameters.workflows[] - ], - unresolved_threads: [ - $threads[][].data.repository.pullRequest.reviewThreads.nodes[]? - | select(.isResolved == false and .isOutdated == false) - ] - }' > "$VERDICT_TMP" - - RECHECKED_PR_JSON="$EVIDENCE_DIR/pr-${PR}-rechecked.json" - RECHECKED_HEAD_SHA=$(gh api "repos/ContextualWisdomLab/OriginWeave/pulls/$PR" \ - | tee "$RECHECKED_PR_JSON" \ - | jq -r '.head.sha') - RECHECKED_BASE_SHA=$(jq -r '.base.sha' "$RECHECKED_PR_JSON") - if [[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" && "$RECHECKED_BASE_SHA" == "$BASE_SHA" ]]; then - mv "$VERDICT_TMP" "$VERDICT_PATH" - mv "$RECHECKED_PR_JSON" "$PR_JSON" - STABLE_HEAD=true - break - fi - rm -f "$VERDICT_TMP" "$RECHECKED_PR_JSON" - printf 'Discarding moving head/base evidence for PR #%s (head %s -> %s, base %s -> %s) and retrying.\n' \ - "$PR" "$HEAD_SHA" "$RECHECKED_HEAD_SHA" "$BASE_SHA" "$RECHECKED_BASE_SHA" >&2 - done - if [[ "$STABLE_HEAD" != true ]]; then - rm -f "$EVIDENCE_DIR"/pr-${PR}-*.json - printf 'Unable to collect stable exact-head/base evidence for PR #%s after 3 attempts.\n' "$PR" >&2 - exit 1 - fi -done +gh api "repos/$repo/pulls/148" +gh api "repos/$repo/issues/212" +gh api "repos/$repo/issues/279" ``` -The branch-scoped rules response determines the active rules affecting `main`; each PR's exact `HEAD_SHA` then determines which check runs, legacy statuses, workflow runs, reviews, and unresolved threads are current. The saved merge verdict binds counted approvals to the latest review per eligible collaborator, excludes the PR author, and requires `APPROVED` on the exact head. It deliberately does **not** infer GitHub's actual last-push actor from commit author or committer metadata: when `require_last_push_approval` is active, this portable evidence procedure records `github_rule_evaluation_required` and keeps `approval_gate_satisfied` false until GitHub's authoritative rule evaluation is consulted. The saved PR JSON also preserves the exact base reference and branch ancestry input for the dependency graph. Evidence is retained only when both `RECHECKED_HEAD_SHA` and `RECHECKED_BASE_SHA` match the collected values; a moving head or base discards the temporary verdict, and three failed attempts leave no unstable merge verdict. - -For standards and binding architecture, use [`doctoring.md`](doctoring.md), [`doctoring/browser-agent-protocols.md`](doctoring/browser-agent-protocols.md), [`PRD.md`](PRD.md), [`TRD.md`](TRD.md), [`product-roadmap.md`](product-roadmap.md), and linked ADR/UML/ERD/traceability records. Issues #199-#203 contain their own APA 7th standards and research traceability. This baseline intentionally records delivery state and never promotes planned adapters or active pull-request code to implemented behavior. +Re-fetch the head and base immediately before any merge/readiness decision. If either moved, previous check/review evidence becomes lineage only until the new exact head is verified. diff --git a/tests/test_gap_snapshot_inventory_consistency.py b/tests/test_gap_snapshot_inventory_consistency.py index 0daca1f85..aad56deb5 100644 --- a/tests/test_gap_snapshot_inventory_consistency.py +++ b/tests/test_gap_snapshot_inventory_consistency.py @@ -1,57 +1,44 @@ -"""Regression contracts for the current dated product-gap inventory snapshot.""" +"""Regression contracts for the current product-gap inventory snapshot.""" from __future__ import annotations from pathlib import Path +import re import unittest - ROOT = Path(__file__).resolve().parents[1] BASELINE = ROOT / "docs" / "product-technical-gap-baseline.md" -CHANGELOG = ROOT / "CHANGELOG.md" class GapSnapshotInventoryConsistencyTests(unittest.TestCase): - """Prevent one dated snapshot from carrying contradictory live PR totals.""" + """Prevent the canonical snapshot from carrying contradictory live PR totals.""" @classmethod def setUpClass(cls) -> None: cls.baseline = BASELINE.read_text(encoding="utf-8") - cls.changelog = CHANGELOG.read_text(encoding="utf-8") - - def test_current_baseline_inventory_matches_the_verified_snapshot(self) -> None: - """The current snapshot must use the exact 126/54/72 inventory observation.""" - current = self.baseline.split("### Open pull requests", 1)[1].split( - "#### 2026-08-26 maintenance-loop record", 1 - )[0] - for marker in ( - "126 open pull requests", - "54 non-draft", - "72 draft", - ): - with self.subTest(marker=marker): - self.assertIn(marker, current) - - for stale in ( - "128 open pull requests", - "74 draft", - "153 open pull requests", - "114 draft", - ): - with self.subTest(stale=stale): - self.assertNotIn(stale, current) - - def test_unreleased_changelog_uses_one_current_inventory(self) -> None: - """The Unreleased current snapshot must agree before and inside Added.""" - unreleased = self.changelog.split("## [Unreleased]", 1)[1] - preamble, remainder = unreleased.split("### Added", 1) - added = remainder.split("### Changed", 1)[0] - - expected = "126 open pull requests (54 ready, 72 draft)" - self.assertIn(expected, preamble) - self.assertIn(expected, added) - self.assertNotIn("128 open pull requests (54 ready, 74 draft)", preamble) - self.assertNotIn("153 open pull requests (39 ready, 114 draft)", added) + + def test_current_inventory_is_internally_consistent(self) -> None: + """Ready plus Draft counts must equal the recorded open-PR total.""" + match = re.search( + r"\*\*(\d+) open pull requests: (\d+) non-draft and (\d+) draft\*\*", + self.baseline, + ) + self.assertIsNotNone(match) + total, non_draft, draft = (int(value) for value in match.groups()) + self.assertEqual((total, non_draft, draft), (125, 12, 113)) + self.assertEqual(non_draft + draft, total) + + def test_current_snapshot_has_one_protected_main_identity(self) -> None: + """The delivery boundary must name the current signed protected head.""" + current = self.baseline.split("## Observed snapshot: 2026-09-06", 1)[1] + self.assertIn("87c4daa1830bac5a5228b6036752ad5633232085", current) + self.assertNotIn("b05d5acca82b9d916ada2c8e82f59f92a89817e1", current) + + def test_evidence_procedure_requires_fresh_head_recheck(self) -> None: + """A stored snapshot must never authorize stale-head promotion.""" + self.assertIn("Re-fetch the head and base immediately before any merge/readiness decision", self.baseline) + self.assertIn("--paginate", self.baseline) + self.assertIn("head_sha=89708cf5e474f7701513b84a1356a8ce1699bef5", self.baseline) if __name__ == "__main__": diff --git a/tests/test_product_completion_gap_contract.py b/tests/test_product_completion_gap_contract.py index 1c24fe674..4f42cf311 100644 --- a/tests/test_product_completion_gap_contract.py +++ b/tests/test_product_completion_gap_contract.py @@ -1,122 +1,72 @@ -"""Regression contract for the dated commercial-completion gap baseline.""" +"""Regression contract for the code-current commercial gap baseline.""" from __future__ import annotations -import pathlib +from pathlib import Path import unittest -ROOT = pathlib.Path(__file__).resolve().parents[1] -BASELINE = ROOT / "docs/product-technical-gap-baseline.md" +ROOT = Path(__file__).resolve().parents[1] +BASELINE = ROOT / "docs" / "product-technical-gap-baseline.md" class ProductCompletionGapContractTests(unittest.TestCase): - """Keep the exact repository snapshot and completion tracks reviewable.""" + """Keep buyer gaps tied to the current repository snapshot and authority model.""" - def test_baseline_records_current_inventory_and_completion_issues(self) -> None: - """The dated baseline must not retain superseded queue counts or omit buyer tracks.""" - text = BASELINE.read_text(encoding="utf-8") + @classmethod + def setUpClass(cls) -> None: + cls.text = BASELINE.read_text(encoding="utf-8") + def test_baseline_records_current_inventory_and_protected_head(self) -> None: + """The current snapshot must not retain the superseded August inventory as current.""" for phrase in ( - "126 open pull requests", - "54 non-draft", - "72 draft", - "2026-08-24 158-PR snapshot", - "#198", - "#199", - "#200", - "#201", - "#202", - "#203", - "durable WARC/PROV replay", - "stable BAP/MCP runtime API", - "signed cross-platform Chromium distribution", - "enterprise control and experience plane", - "commercial acceptance gate", + "## Observed snapshot: 2026-09-06", + "87c4daa1830bac5a5228b6036752ad5633232085", + "125 open pull requests", + "12 non-draft", + "113 draft", + "13 open non-PR issues", + "0 GitHub Releases", ): with self.subTest(phrase=phrase): - self.assertIn(phrase, text) + self.assertIn(phrase, self.text) for stale_phrase in ( - "100 open pull requests", - "22 non-draft", - "78 draft", - "148 open pull requests", - "79 draft PRs", - "40 non-draft", - "110 draft", - "150 open pull requests", - "prior 150-PR snapshot", - "128 open pull requests", - "74 draft", + "126 open pull requests", + "54 non-draft and 72 draft", + "Protected `main` is at `b05d5acca82b9d916ada2c8e82f59f92a89817e1`", ): with self.subTest(stale_phrase=stale_phrase): - self.assertNotIn(stale_phrase, text) - - def test_active_github_approval_rule_is_not_documented_as_bypassable(self) -> None: - """An active counted-approval rule must stop merge without an eligible approver.""" - text = BASELINE.read_text(encoding="utf-8") - - self.assertIn("eligible non-author", text) - self.assertIn("reviewer-provisioning gap", text) - self.assertNotIn("owner-directed administrative merge", text) - - def test_evidence_commands_reproduce_inventory_checks_and_review_state(self) -> None: - """The evidence procedure must paginate the queue and inspect each exact PR head.""" - text = BASELINE.read_text(encoding="utf-8") - evidence = text.split("## Evidence commands", 1)[1].split("\n## ", 1)[0] - shell = evidence.split("```bash", 1)[1].split("```", 1)[0] + self.assertNotIn(stale_phrase, self.text) + def test_current_blockers_and_owner_paths_are_explicit(self) -> None: + """Commercial completion must retain the exercised browser and control-plane gaps.""" for phrase in ( - "--paginate --slurp 'repos/ContextualWisdomLab/OriginWeave/pulls?state=open&per_page=100'", - "set -euo pipefail", - 'EVIDENCE_DIR="$(mktemp -d /tmp/originweave-evidence.XXXXXX)"', - '"$EVIDENCE_DIR/open-pr-pages.json"', - "jq '[.[][]]' \"$EVIDENCE_DIR/open-pr-pages.json\"", - '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR"', - '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/check-runs?per_page=100"', - '"repos/ContextualWisdomLab/OriginWeave/commits/$HEAD_SHA/statuses?per_page=100"', - '"repos/ContextualWisdomLab/OriginWeave/pulls/$PR/reviews?per_page=100"', - '"repos/ContextualWisdomLab/OriginWeave/actions/runs?head_sha=$HEAD_SHA&per_page=100"', - "check_runs: [$checks[][].check_runs[]?],", - "legacy_statuses: [$statuses[][][]?]", - "workflow_runs: [$workflow_runs[][].workflow_runs[]?],", - "reviewThreads(first: 100, after: $endCursor)", - "rules/branches/main?per_page=100", - '"$EVIDENCE_DIR/main-branch-rule-pages.json"', - '"$EVIDENCE_DIR/collaborator-pages.json"', - '"$EVIDENCE_DIR/collaborators.json"', - '"$EVIDENCE_DIR/pr-${PR}-merge-verdict.json.tmp"', - '.state == "APPROVED"', - ".submitted_at != null", - ".commit_id == $head", - "group_by(.reviewer)", - "required_approving_review_count", - "require_last_push_approval", - "last_push_approval_authority", - '"github_rule_evaluation_required"', - "if $pull_request_parameters.require_last_push_approval == true then false", - "$pr[0].user.login", - '.type == "workflows"', - ".parameters.workflows", - "required_status_checks", - '"$EVIDENCE_DIR/pr-${PR}-merge-verdict.json"', - "for ATTEMPT in 1 2 3; do", - "RECHECKED_HEAD_SHA=", - "RECHECKED_BASE_SHA=", - 'if [[ "$RECHECKED_HEAD_SHA" == "$HEAD_SHA" && "$RECHECKED_BASE_SHA" == "$BASE_SHA" ]]; then', + "89708cf5e474f7701513b84a1356a8ce1699bef5", + "#242", + "0135984f1bc1f68d89d7777f49c4999474105a12", + "failure_stage=session_create", + "Issue #212", + "Issue #279", + "ContextualWisdomLab/.github#712", + "No predecessor GREEN transfers", + "Command ACK is never sufficient", ): with self.subTest(phrase=phrase): - self.assertIn(phrase, shell) + self.assertIn(phrase, self.text) - self.assertNotIn("while :; do", shell) - self.assertNotIn("/tmp/originweave-open-pr", shell) - self.assertNotIn("check_runs: [$checks[]?.check_runs[]?],", shell) - self.assertNotIn("legacy_statuses: [$statuses[][]?]", shell) - self.assertNotIn("workflow_runs: [$workflow_runs[]?.workflow_runs[]?],", shell) - self.assertNotIn("$reviews[][]?\n | select(.state", shell) - self.assertNotIn("head-commit.json", shell) - self.assertNotIn("$head_commit[0].committer.login", shell) - self.assertNotIn("$head_commit[0].author.login", shell) + def test_release_and_dependency_boundaries_remain_fail_closed(self) -> None: + """The baseline must require an immutable release and versioned owner contracts.""" + for phrase in ( + "signed cross-platform artifacts", + "SBOM/provenance", + "reproducibility", + "rollback", + "released/versioned contracts or ACLs", + "cross-service SQL", + "mutable sibling heads", + ): + with self.subTest(phrase=phrase): + self.assertIn(phrase, self.text) if __name__ == "__main__": From bd93c3196d3dba548e26119d3f34eb3bb6d55854 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 12:20:17 +0900 Subject: [PATCH 559/570] docs(architecture): restore live gap-baseline link --- ARCHITECTURE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index be1bd6096..3a60ca799 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,6 +12,7 @@ This file is the canonical product-wide topology and bounded-context view. It is - [Requirement, decision, standards, and implementation traceability](docs/traceability/README.md) - [Research and standards doctoring](docs/doctoring.md) - [Product roadmap](docs/product-roadmap.md) +- [Live product and technical gap baseline](docs/product-technical-gap-baseline.md) Protected-main code and executable tests define current implementation truth; deployed build/release artifacts, migrations, and configuration are additional operational evidence when they exist. Accepted ADRs define design authority, not proof that planned behavior has shipped. The PRD/TRD/diagrams may also contain `Planned`, `Proposed`, or `Open` product direction; those labels must remain explicit until corresponding implementation and review evidence reaches protected `main`. From 2c6d49d03cf4610083ba932ff466db6f55e7bc37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 12:21:28 +0900 Subject: [PATCH 560/570] docs(index): restore product and BAP decision links --- docs/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/README.md b/docs/README.md index 03b573c54..1ea57ad29 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ - [OriginWeave API and protocol contract](API_CONTRACT.md) - [Release and rollback contract](RELEASE_AND_ROLLBACK.md) - [Product roadmap](product-roadmap.md) +- [Product and technical gap baseline](product-technical-gap-baseline.md) - [Research and standards](doctoring.md) - [Browser and Agent protocol standards evidence](doctoring/browser-agent-protocols.md) - [Current product-baseline standards addendum](doctoring/product-documentation-baseline.md) @@ -86,4 +87,12 @@ Proposed ADRs are reviewable architecture memory, not shipped behavior and not a The second group exists only on this documentation branch until the branch integrates. After integration, the heading remains useful historical provenance; it does not promote either ADR from Proposed to Accepted and it does not claim that the described runtime capability is implemented. +### Proposed decisions introduced by active feature work + +- [ADR 0016: BAP task lifecycle and state authority](adr/0016-bap-task-lifecycle-authority.md) + +ADR 0016 is owned by this active BAP lifecycle feature branch and remains Proposed. Its presence here makes the branch documentation graph complete without presenting the decision or implementation as protected-main truth before integration. + +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 from Proposed or assert implementation maturity. + See the [ADR index](adr/README.md) for status rules, required decision structure, supersession rules, and active feature ADRs. The index and each ADR's own status metadata must agree; a PR body, chat transcript, automation prompt, or stale issue reference cannot change ADR status. From e6ab15bdac43df1e3598a66c0a808d7e233de333 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 12:21:49 +0900 Subject: [PATCH 561/570] docs(adr): restore BAP lifecycle decision index --- docs/adr/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/adr/README.md b/docs/adr/README.md index 416231b1c..5f9e2a878 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,6 +57,16 @@ Proposed ADR files are reviewable target architecture without becoming Accepted ADR 0013 and ADR 0014 exist only on this documentation branch until it integrates. After integration, this subsection remains historical provenance rather than an active-PR claim; both decisions remain Proposed until a later policy-compliant change explicitly changes their lifecycle. +### Proposed decisions introduced by active feature work + +| ADR | Decision | Status | Governs | +|---|---|---|---| +| [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 | + +ADR 0016 belongs to the active BAP lifecycle feature branch. Indexing it makes the branch documentation graph complete while preserving its Proposed lifecycle and 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 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. ## Index completeness rule From c215d3ca428a783a0a9f31be0b5b8d4ccbaf1c04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 12:22:56 +0900 Subject: [PATCH 562/570] test(docs): preserve gap and BAP decision discoverability --- ...st_product_gap_discoverability_contract.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/test_product_gap_discoverability_contract.py diff --git a/tests/test_product_gap_discoverability_contract.py b/tests/test_product_gap_discoverability_contract.py new file mode 100644 index 000000000..674bba62b --- /dev/null +++ b/tests/test_product_gap_discoverability_contract.py @@ -0,0 +1,35 @@ +"""Regression contracts for discoverability of the commercial gap baseline.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +BASELINE = ROOT / "docs" / "product-technical-gap-baseline.md" + + +class ProductGapDiscoverabilityContractTests(unittest.TestCase): + """Keep the code-current buyer gap baseline reachable from canonical indexes.""" + + def test_gap_baseline_exists_and_is_linked_from_canonical_indexes(self) -> None: + """Architecture and documentation readers must not reconstruct the baseline from PR history.""" + self.assertTrue(BASELINE.is_file()) + architecture = (ROOT / "ARCHITECTURE.md").read_text(encoding="utf-8") + documentation_index = (ROOT / "docs" / "README.md").read_text(encoding="utf-8") + self.assertIn("docs/product-technical-gap-baseline.md", architecture) + self.assertIn("product-technical-gap-baseline.md", documentation_index) + + def test_bap_lifecycle_adr_is_indexed_without_premature_acceptance(self) -> None: + """Restoring the ADR file must restore discoverability without changing its lifecycle.""" + adr = (ROOT / "docs" / "adr" / "0016-bap-task-lifecycle-authority.md").read_text( + encoding="utf-8" + ) + index = (ROOT / "docs" / "adr" / "README.md").read_text(encoding="utf-8") + self.assertIn("- Status: Proposed", adr) + self.assertIn("0016-bap-task-lifecycle-authority.md", index) + self.assertIn("| Proposed |", index) + + +if __name__ == "__main__": + unittest.main() From b9907dc89ff2b8c388bf0c03973ddf27c5063e89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 12:23:58 +0900 Subject: [PATCH 563/570] repair(docs): restore product authority contracts --- docs/adr/0106-provenance-evidence-model.md | 20 ++++++++++++++++++- .../0107-browser-protocol-adapter-strategy.md | 20 ++++++++++++++++--- .../extension-authority-security.md | 6 ++++++ tests/test_repository_contract.py | 13 +++++++++++- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/docs/adr/0106-provenance-evidence-model.md b/docs/adr/0106-provenance-evidence-model.md index 0e2741f37..09cb0d7ca 100644 --- a/docs/adr/0106-provenance-evidence-model.md +++ b/docs/adr/0106-provenance-evidence-model.md @@ -33,29 +33,47 @@ OriginWeave maintains provenance-native evidence with stable identifiers for ses WARC and PROV are interoperability/export contracts, not substitutes for OriginWeave's internal authorization or evidence schema. A WARC record can contain untrusted or sensitive payload bytes and therefore inherits capture, retention, encryption, and export policy. A PROV entity/activity/agent relation records derivation or responsibility; it cannot manufacture authentication, authorization, durable completion, or tenant ownership not established by the producing system. +### Versioned extraction-schema binding + +A versioned `ExtractionSchema` is the binding contract for typed extraction before any capture persistence or export format is allowed to claim semantic authority. Each schema version contains an ordered, non-empty set of unique `ExtractionField` definitions. Schema-version and field identifiers are bounded to 128 encoded bytes, begin with a lowercase ASCII letter, and thereafter admit only lowercase ASCII letters, digits, `_`, or `-`. One schema admits at most 256 fields. + +Every extraction field binds its stable identifier to a value type, cardinality, required/optional status, deterministic normalization rule, and a non-empty duplicate-free set of reviewed source-channel classes. Cardinality and required status form one internally consistent presence contract: `One` is necessarily required, `ZeroOrOne` is necessarily optional, and `Many` may be marked required or optional because this value-object layer does not yet define a minimum collection item count. Contradictory `One`/optional or `ZeroOrOne`/required declarations fail closed during field construction. `Verbatim` is the compatibility default used by the existing constructor. `TrimTextWhitespace` is admitted only for text fields and `Rfc3339Utc` only for timestamp fields; type-incompatible normalization fails closed. A `ModelInterpretation` source channel is classification metadata only and does not grant model execution, approval, disclosure, browser, network, secret, or storage authority. + +At this value-object boundary, the version identifier is immutable schema identity; there is deliberately no registry that silently treats two different field contracts as compatible merely because their version strings compare or sort in a particular way. Callers changing a field identifier, value type, cardinality, required status, normalization rule, or admitted source-channel set must use a distinct reviewed schema version and perform any migration/compatibility decision at an explicit higher layer. The current schema object does not itself read browser data, materialize extracted values, persist artifacts, execute models, or change governance policy. Those capabilities require separately authorized runtime boundaries and are not implied by schema construction. + ## Consequences Capture becomes a designed product surface rather than incidental logging. Storage and retention need budgets. Consumers can distinguish a model claim from source evidence and an action request from verified completion. Export adapters can target WARC, provenance graphs, audit streams, or buyer-specific schemas. +A schema consumer can also determine the exact field/type/cardinality/normalization/source contract it reviewed rather than relying on free-form extraction instructions. Schema evolution is explicit instead of being inferred from mutable field definitions; runtime compatibility, migrations, durable storage, and extracted-value validation remain separate implementation work until those boundaries are delivered. + ## Failure and degraded behavior If mandatory evidence cannot be recorded durably enough for a governed state-changing action, the action fails before execution or reports an explicit unverifiable failure; it is never marked proved. Read-only operations may degrade to reduced evidence only when the API contract declares that mode. Corrupt or incomplete evidence is quarantined rather than silently accepted. +Invalid or oversized extraction identifiers, contradictory cardinality/required declarations, empty or duplicate field sets, missing or duplicate source channels, and type-incompatible normalization rules fail during schema construction. A caller must not reinterpret such a failure as an empty/default-success schema or silently substitute another source channel. + ## Security / privacy / governance impact Evidence is tenant-scoped, selectively disclosed, encrypted as appropriate, retention-bounded, and auditable. Credential-bearing headers, cookies, secret values, and sensitive form data are excluded or transformed according to explicit schema policy. Integrity metadata and immutable artifact identities support tamper detection without claiming external certification. `docs/DATA_GOVERNANCE.md` defines the disclosure/retention boundary for protected content and derived artifacts. +The extraction-schema contract does not modify governance authority. It describes admissible typed fields and reviewed evidence-channel classes only. In particular, declaring `NetworkResponse` or `ModelInterpretation` does not authorize network access, model execution, protected-data disclosure, approvals, retention, or export; those remain governed by their existing owning boundaries. + ## Tests and acceptance evidence Require provenance-link tests, credential-leak tests, integrity/corruption tests, crash-recovery tests, WARC/export conformance where implemented, PROV relation/schema tests where implemented, retention/deletion tests, tenant-isolation tests, and end-to-end checks that state-changing actions link request, policy, approval, execution, and post-condition as separate records. Export tests must prove that disabled or unauthorized source bodies never appear merely because metadata provenance is exportable. +The extraction-schema boundary additionally requires tests for the identifier grammar and limits, field-count bound, duplicate identifiers, source-channel presence and uniqueness, every reviewed value/cardinality/source-channel variant, consistent cardinality/required combinations and contradictory-combination rejection, deterministic normalization selection, incompatible normalization rejection, and the backward-compatible `Verbatim` constructor default. + ## Migration and rollback Introduce stable evidence identifiers and schema versions before changing export formats. Migrations preserve old evidence semantics or explicitly mark unavailable fields. Rollback may revert an exporter but cannot collapse mandatory action and policy evidence into opaque logs. +Extraction contract changes that alter field identity or semantics require a new reviewed schema version rather than mutating the meaning of an existing version. Rolling back a consumer may stop accepting a newer version, but it must not reinterpret that newer contract as an older one or silently discard required fields. + ## Open follow-ups -Finalize canonical evidence schemas, content-retention defaults, signing/attestation strategy, cross-system export identifiers, and buyer-controlled disclosure policies. +Finalize canonical evidence schemas, content-retention defaults, signing/attestation strategy, cross-system export identifiers, and buyer-controlled disclosure policies. Add the runtime that validates concrete extracted values against an `ExtractionSchema`, plus explicit migration/compatibility policy when durable schema registration is introduced. ## Supersession / reversal conditions diff --git a/docs/adr/0107-browser-protocol-adapter-strategy.md b/docs/adr/0107-browser-protocol-adapter-strategy.md index 8923616be..fb1bf2e17 100644 --- a/docs/adr/0107-browser-protocol-adapter-strategy.md +++ b/docs/adr/0107-browser-protocol-adapter-strategy.md @@ -34,6 +34,16 @@ OriginWeave exposes its own versioned protocol for session, observation, query, MCP version negotiation is independent of the OriginWeave Protocol version. As of this review, MCP `2026-07-28` is the current released protocol generation; a future MCP change does not silently alter OriginWeave task, approval, secret, tenant, or browser semantics. MCP tool/resource content remains untrusted input and any server-to-client/user interaction capability is mediated by the same OriginWeave policy/approval boundaries as other adapter traffic. +### Current implementation boundary + +The complete MCP adapter remains **Planned**. Protected main now contains the narrower bounded Rust `tools/call` routing/action-policy foundation merged through PR #168. That protected-main foundation validates the `2026-07-28` stateless `tools/call` routing envelope presented to this boundary, bounds and syntax-checks both untrusted method fields and both untrusted tool-name fields before cross-field correlation, derives one of the existing typed `ActionKind` values from a deterministic reviewed registry, exposes discovery metadata from that same registry, and requires the resulting action to pass the ordinary OriginWeave policy evaluator. The method boundary accepts only nonempty ASCII method names up to 64 bytes using the reviewed routing alphabet, while the tool-name boundary accepts only nonempty ASCII names up to 128 bytes using its narrower reviewed alphabet. The catalog and validated route grant no capability, approval, origin, secret, browser, persistence, or evidence authority by themselves. + +Active PR #170 is a separate non-shipped refinement on top of that protected-main catalog. It adds one conservative typed `tools/list` request/result contract: both protocol-version fields are required and bounded before comparison, client-capability metadata must be present without becoming authority, both routing/body methods are syntax-bounded before correlation, only exact `tools/list` is admitted, and every caller-supplied cursor is rejected because the current fixed catalog issues none. The result is one complete page with zero freshness, private cache scope, and no continuation cursor. + +Neither protected main nor PR #170 implements Streamable HTTP transport parsing, JSON-RPC/HTTP serialization, OAuth, browser I/O, WebMCP/BiDi/CDP translation, secret delivery, persistence, general pagination/subscription state, or a complete OriginWeave Protocol adapter. Those remain separate adapter/runtime work. Protected `main` may therefore describe only the bounded merged `tools/call` foundation as implemented; the full MCP adapter remains planned, and the `tools/list` refinement remains active-PR evidence until separately integrated. + +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. + ## Consequences OriginWeave carries adapter maintenance and version negotiation but gains a durable customer API. Multiple browser/control transports can coexist. New upstream capabilities do not silently change risk or action semantics. Compatibility matrices become release artifacts. @@ -44,19 +54,21 @@ Adapter negotiation failure disables only affected capabilities. Unsupported or ## Security / privacy / governance impact -Protocol validation occurs before messages influence policy. Tool/page-provided strings remain untrusted. Secret handles never become raw secret protocol payloads; only the separately authorized trusted broker-to-browser delivery path may materialize the value, and that value does not pass through MCP, WebMCP, BiDi observation, or model-visible CDP output. Adapter version/provenance is recorded for audit and incident reconstruction. +Protocol validation occurs before messages influence policy. Tool/page-provided strings remain untrusted. Method and tool routing metadata is shape-bounded before correlation, preventing malformed or oversized untrusted routing strings from being reinterpreted through mismatch handling. Secret handles never become raw secret protocol payloads; only the separately authorized trusted broker-to-browser delivery path may materialize the value, and that value does not pass through MCP, WebMCP, BiDi observation, or model-visible CDP output. Adapter version/provenance is recorded for audit and incident reconstruction. ## Tests and acceptance evidence Require version-negotiation tests, schema/property tests, malformed-message tests, BiDi/CDP semantic parity tests for shared capabilities, WebMCP prompt-injection tests, MCP authority-separation and version-change tests, browser-version compatibility matrices, and end-to-end proof that unsupported capabilities fail without side effects. +For the protected-main `tools/call` foundation, acceptance includes deterministic method and tool-name bounds/syntax, exact header/body method and tool-name correlation only after both sides are bounded, explicit invalid-method/invalid-tool-name/unknown-tool rejection, one unambiguous tool-to-action registry, independent capability/risk expectations, route/action mismatch denial before ordinary policy evaluation, exact 100% owned-production coverage, and integrated review evidence from PR #168. For active PR #170, exact-current acceptance additionally requires bounded protocol metadata before cross-field comparison, required client-capabilities presence, bounded `tools/list` method correlation, rejection of unissued cursors, deterministic result/cache semantics, exact 100% owned-production coverage, and unchanged-head CI/security/review evidence. These checks do not substitute for complete transport or adapter conformance. + ## Migration and rollback Adapters are independently versioned and can be canaried. Clients migrate through OriginWeave Protocol compatibility rules, not upstream protocol rewrites. Rollback pins a previously supported adapter/browser/protocol pair and records that pair in provenance. ## Open follow-ups -Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, and MCP/WebMCP schema isolation. +Define internal protocol versioning rules, adapter capability descriptors, minimum supported BiDi level, CDP pin policy, complete MCP Streamable HTTP/request-metadata validation, MCP transport serialization, authenticated deployment, and MCP/WebMCP schema isolation. ## Supersession / reversal conditions @@ -68,10 +80,12 @@ Chrome DevTools Protocol. (2026). *Chrome DevTools Protocol — latest (tip-of-t Chrome DevTools Protocol. (2026). *WebMCP domain*. Chromium. Retrieved August 9, 2026, from https://chromedevtools.github.io/devtools-protocol/tot/WebMCP/ +Model Context Protocol. (2026, July 28). *Specification: 2026-07-28*. https://modelcontextprotocol.io/specification/2026-07-28 + 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, June 29). *WebDriver BiDi* [Working Draft]. https://www.w3.org/TR/2026/WD-webdriver-bidi-20260629/ ## Related documents -See `docs/API_CONTRACT.md`, `docs/TRD.md`, `docs/doctoring/product-documentation-baseline.md`, and `docs/DATA_GOVERNANCE.md`. +See `docs/API_CONTRACT.md`, `docs/TRD.md`, `docs/doctoring.md`, `docs/doctoring/product-documentation-baseline.md`, `docs/traceability/README.md`, and `docs/DATA_GOVERNANCE.md`. diff --git a/docs/traceability/extension-authority-security.md b/docs/traceability/extension-authority-security.md index a36380a31..1c211f83d 100644 --- a/docs/traceability/extension-authority-security.md +++ b/docs/traceability/extension-authority-security.md @@ -50,6 +50,12 @@ Exact head `e83749acd1cf5a0b778ba38eb9d6ed5a9bd1e68f` deliberately keeps only th The exact head has successful CI, exact owned production coverage, Security Scan, SAST and CodeRabbit status and is Ready for review. It has no raw secret bytes and does not create approval evidence, a broker, browser-fill adapter, protected-value store, KMS path, authenticated workload identity, persistence owner, or release claim. +### Origin-bound extension grant evaluation + +**Capability maturity:** `IMPLEMENTED_ON_ACTIVE_PR` + +The current origin-binding slice requires `ExtensionAgentGrant` and `ExtensionAccessRequest` to carry the same canonical origin. A same-session, same-context request for `https://other.example` or `https://app.example:8443` against a grant for `https://app.example` is `DenyOriginMismatch`. Exclusive trusted-time expiry is evaluated after that origin match: `now >= expires_at` is `DenyExpired`. This does not install an extension, parse Chrome messages, bind task identity, or mint Agent capabilities from Manifest V3 permissions. + ## 4. Security interpretation The executable authority chain is intentionally non-transitive: diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 360e11143..00ceb5a12 100644 --- a/tests/test_repository_contract.py +++ b/tests/test_repository_contract.py @@ -20,6 +20,7 @@ def test_workspace_declares_all_independently_reusable_crates(self) -> None: set(data["workspace"]["members"]), { "crates/originweave-core", + "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-destination", "crates/originweave-network", @@ -59,6 +60,7 @@ def test_required_architecture_and_governance_documents_exist(self) -> None: "docs/adr/0005-direct-socket-binding.md", "docs/adr/0006-tls-server-identity.md", "docs/adr/0009-hourly-agent-credential-boundary.md", + "docs/adr/0016-bap-task-lifecycle-authority.md", "docs/superpowers/specs/2026-08-06-resolved-destination-policy-design.md", "docs/superpowers/specs/2026-08-06-direct-socket-binding-design.md", "docs/superpowers/specs/2026-08-06-tls-server-identity-design.md", @@ -118,6 +120,15 @@ def test_ci_validates_the_exact_pull_request_head(self) -> None: self.assertIn(f"exact-coverage-{exact_head}", workflow) self.assertIn("permissions:\n contents: read", workflow) self.assertNotIn("contents: write", workflow) + self.assertIn("${{ github.workflow }}-${{ github.repository }}", workflow) + self.assertIn("${{ github.event.pull_request.number || github.run_id }}", workflow) + self.assertIn("cancel-in-progress: ${{ github.event_name == 'pull_request' }}", workflow) + self.assertIn( + "types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed]", + workflow, + ) + self.assertEqual(workflow.count("github.event.pull_request.draft == false"), 2) + self.assertNotIn("cargo check --locked --workspace --all-targets", workflow) def test_hourly_loop_uses_nvidia_nim_and_dedicated_publication_authority(self) -> None: """The product loop must use OpenCode/NIM without review or merge credentials.""" @@ -185,4 +196,4 @@ def test_database_contract_requires_two_word_snake_case(self) -> None: if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From 64114aab9e000f9cdc017f68e6c926e5abf28df3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 12:24:56 +0900 Subject: [PATCH 564/570] docs(readme): restore protected MCP product boundary --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 17085c05d..0942976cf 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ OriginWeave is a Chromium-compatible, Rust-first control plane for governed AI agents on the web. It is designed to let an agent observe, extract, and act without turning untrusted page content into authority, exposing secrets to a model, connecting to an unapproved network destination, accepting an unauthenticated web service, or losing the evidence required to explain what happened. -> Project status: pre-alpha. The current repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, and authenticated TLS service-identity kernels. Chromium, WebDriver BiDi, CDP, MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. +> Project status: pre-alpha. The current protected repository contains independently reusable safety, resolved-destination, direct TCP peer-binding, authenticated TLS service-identity, and bounded MCP `2026-07-28` stateless `tools/call` routing/policy foundations. Chromium, WebDriver BiDi, CDP, complete MCP, HTTP, proxy, WARC, and persistent provenance adapters are planned but not yet shipped. Active PR #170 implements only conservative `tools/list` discovery metadata on top of the protected-main MCP catalog; it remains non-shipped active-PR evidence and does not make the complete MCP adapter available. ## Why OriginWeave @@ -40,6 +40,8 @@ The repository is organized as independently consumable Rust crates: - `originweave-resource`: task-level RAM, VRAM, thread, and frame-time budgets with cumulative mitigation plans. - `originweave-evidence`: universally value-redacted network evidence and source-bound provenance records. +Protected main additionally contains an `originweave-core` MCP routing registry and `originweave-policy` binding for the MCP `2026-07-28` `tools/call` boundary. That shipped foundation validates and maps an explicit tool name to an existing typed action while preserving normal OriginWeave policy. Active PR #170 adds non-shipped conservative `tools/list` discovery metadata derived from the same reviewed catalog. Neither boundary implements transport parsing, OAuth, browser control, secret materialization, persistence, or ambient authority. + See [ARCHITECTURE.md](ARCHITECTURE.md) and the [architecture decision records](docs/adr/) for binding design decisions. ## Safety model @@ -97,7 +99,7 @@ isolated Chromium session → redacted provenance bundle ``` -Subsequent work connects the live Chromium network service, adds explicit proxy and download policy, WARC/PROV persistence, MCP and Browser Agent Protocol adapters, extension compatibility testing, GPU/RAM telemetry, prompt-injection benchmarks, and an accessible approval interface. See [docs/product-roadmap.md](docs/product-roadmap.md). +Subsequent work connects the live Chromium network service, adds explicit proxy and download policy, WARC/PROV persistence, completes the MCP and Browser Agent Protocol adapters beyond the protected-main `tools/call` foundation and active `tools/list` refinement, expands extension compatibility testing, adds GPU/RAM telemetry and prompt-injection benchmarks, and builds an accessible approval interface. See [docs/product-roadmap.md](docs/product-roadmap.md). ## Hourly product-development loop @@ -109,4 +111,4 @@ Read [AGENTS.md](AGENTS.md), [CONTRIBUTING.md](CONTRIBUTING.md), and [SECURITY.m ## License -Apache License 2.0. See [LICENSE](LICENSE). +Apache License 2.0. See [LICENSE](LICENSE). \ No newline at end of file From dbf3de6688ec2f714b31c85fa2751eee18267c60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 13:04:11 +0900 Subject: [PATCH 565/570] test(gaps): reject self-stale foundation head evidence --- tests/test_gap_snapshot_inventory_consistency.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_gap_snapshot_inventory_consistency.py b/tests/test_gap_snapshot_inventory_consistency.py index aad56deb5..305674f99 100644 --- a/tests/test_gap_snapshot_inventory_consistency.py +++ b/tests/test_gap_snapshot_inventory_consistency.py @@ -34,11 +34,17 @@ def test_current_snapshot_has_one_protected_main_identity(self) -> None: self.assertIn("87c4daa1830bac5a5228b6036752ad5633232085", current) self.assertNotIn("b05d5acca82b9d916ada2c8e82f59f92a89817e1", current) - def test_evidence_procedure_requires_fresh_head_recheck(self) -> None: - """A stored snapshot must never authorize stale-head promotion.""" - self.assertIn("Re-fetch the head and base immediately before any merge/readiness decision", self.baseline) + def test_evidence_procedure_re_resolves_mutable_pr_head(self) -> None: + """Evidence commands must resolve the live PR head instead of freezing a self-stale SHA.""" + self.assertIn( + "Re-fetch the head and base immediately before any merge/readiness decision", + self.baseline, + ) self.assertIn("--paginate", self.baseline) - self.assertIn("head_sha=89708cf5e474f7701513b84a1356a8ce1699bef5", self.baseline) + self.assertIn('foundation_head="$(gh api "repos/$repo/pulls/195" --jq ".head.sha")"', self.baseline) + self.assertIn('commits/$foundation_head/check-runs', self.baseline) + self.assertIn('head_sha=$foundation_head', self.baseline) + self.assertNotIn("head_sha=89708cf5e474f7701513b84a1356a8ce1699bef5", self.baseline) if __name__ == "__main__": From 7b2f30941ba2a2b17870f31a835a3ed0217be17c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 13:05:02 +0900 Subject: [PATCH 566/570] docs(gaps): resolve mutable foundation head at evidence time --- docs/product-technical-gap-baseline.md | 38 ++++++++++++++++---------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index d6acbb35e..c1f040d64 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -14,31 +14,38 @@ This file is the current delivery baseline for OriginWeave. It records buyer-vis ### Foundation and stack integrity -The active WebDriver BiDi stack inherited a historical whole-tree replacement, `5c111d0db6c363f9d1786c21cc01c5c7398007bd` (`fix(stack): restore opening-write prerequisite tree`), that restored its transport prerequisite but also removed unrelated valid product/source/test/documentation assets. That deletion is a repair finding rather than grounds to close dependent PRs. +The active WebDriver BiDi stack inherited historical whole-tree replacement `5c111d0db6c363f9d1786c21cc01c5c7398007bd` (`fix(stack): restore opening-write prerequisite tree`). It restored its transport prerequisite while also removing unrelated valid product/source/test/documentation assets. That deletion is a repair finding rather than grounds to close dependent PRs. -PR #195 is the earliest active owner point currently repairing that foundation. Its exact head is `89708cf5e474f7701513b84a1356a8ce1699bef5` on retained prerequisite #193 `6922dd98779e8f8aad132a3b1f563d7ba6e6d070`. Two ordinary forward commits restore protected product contracts while preserving later browser work: +PR #195 is the earliest active owner point for the foundation recovery. The branch remains based on retained prerequisite #193 `6922dd98779e8f8aad132a3b1f563d7ba6e6d070`. Its recovery lineage includes: -- `29dd314501299a3ad8276e5d73189591ff6327a0` restores the BAP workspace member, MCP and release-acceptance contracts/tests, destination freshness/revalidation, policy MCP binding, resource error contracts, TLS revocation/trust, the Agent Task fixture, and this product/technical gap baseline without replacing the modular WebDriver BiDi core. -- `89708cf5e474f7701513b84a1356a8ce1699bef5` restores extraction schema, sensitive-handle lifecycle, RFC 3986 evidence-path admission, and their tests while retaining `BrowserProtocolValidationEvidence` and its browser-protocol regression. +- `29dd314501299a3ad8276e5d73189591ff6327a0`, which restored the BAP workspace member, MCP and release-acceptance contracts/tests, destination freshness/revalidation, policy MCP binding, resource error contracts, TLS revocation/trust, the Agent Task fixture, and this product/technical gap baseline without replacing the modular WebDriver BiDi core; +- `89708cf5e474f7701513b84a1356a8ce1699bef5`, which restored extraction schema, sensitive-handle lifecycle, RFC 3986 evidence-path admission, and their tests while retaining `BrowserProtocolValidationEvidence` and its browser-protocol regression; and +- later content-aware documentation recovery through `64114aab9e000f9cdc017f68e6c926e5abf28df3`, restoring architecture/index/ADR discoverability, product authority contracts, and the protected MCP product boundary without copying a whole protected tree over later WebDriver work. -The child PR #242 still points at the pre-repair #195 generation and is currently non-mergeable after the base branch advanced. Descendants must therefore adopt the repaired foundation content-aware and non-destructively; preserving an old child tree in a topology-only merge would reintroduce the deleted product assets. Each reconstructed exact head needs fresh checks. No predecessor GREEN transfers. +The baseline deliberately does not embed PR #195's mutable live head as its own current identity. Any commit that updates this file would immediately make such a literal stale. Evidence commands therefore re-resolve the PR head from GitHub before fetching checks. `tests/test_gap_snapshot_inventory_consistency.py` enforces that rule instead of pinning a self-invalidating head SHA. + +PR #242 still targets the pre-recovery #195 generation `48eb2d23009c1c804520dd5efcd0d4d072aacef1` and GitHub currently reports it non-mergeable. Descendants must adopt the repaired foundation content-aware and non-destructively; preserving an old child tree in a topology-only merge would reintroduce the deleted product assets. Each reconstructed exact head needs fresh checks. No predecessor GREEN transfers. ### Browser sandbox and realistic Chromium acceptance PR #148 is exact `0135984f1bc1f68d89d7777f49c4999474105a12`. Its repository CI `33990522263` is terminal success with exact 100% reported production coverage (415 functions, 3,555 lines, 4,444 regions, 476 branches). Its real Manifest V3 Compatibility run `33990522248`, job `101371812631`, is terminal failure on pinned Chrome `150.0.7871.129` after all inherited `--no-sandbox` launch overrides were removed. Artifact `9977680352` reports 0/3 for ordinary MV3, ordinary Agent Task, forced-close Agent Task, and browser-crash Agent Task; the crash lane localizes to `failure_stage=session_create`, `failure_type=RuntimeError`, `reason_code=runtime_error`. Cleanup completion is not browser success. -Issue #212 is the canonical workflow-owner boundary for the missing sandbox-helper integration. PR #43 previously proved that root-owned mode-`4755` `chrome_sandbox` plus `CHROME_DEVEL_SANDBOX` can run the same Chrome generation sandboxed on that leaf generation, but its GREEN does not transfer to #148 or to the current protected workflow. The authorized owner must reconstruct the validated helper mechanics against the current protected MV3 workflow, preserve harden-runner/egress, immutable pins, Draft/closed lifecycle and evidence retention, then consumers must adopt it non-destructively and regenerate exact-head Linux browser evidence. Restoring `--no-sandbox`, reducing trials, or treating cleanup as success is not an acceptable repair. +Issue #212 is the canonical workflow-owner boundary for the missing sandbox-helper integration. PR #43 previously proved that root-owned mode-`4755` `chrome_sandbox` plus `CHROME_DEVEL_SANDBOX` can run the same Chrome generation sandboxed on that leaf generation, but its GREEN does not transfer to #148 or the current protected workflow. The authorized owner must reconstruct the validated helper mechanics against the current protected MV3 workflow, preserve harden-runner/egress, immutable pins, Draft/closed lifecycle and evidence retention, then consumers must adopt it non-destructively and regenerate exact-head Linux browser evidence. Restoring `--no-sandbox`, reducing trials, or treating cleanup as success is not an acceptable repair. + +### WebDriver BiDi navigation stack + +The repaired teardown/navigation chain #255 → #256 → #257 → #258 → #259 → #260 → #261 → #277 has terminal repository-native success on the already-restacked exact heads. That evidence validates those exact trees only; it does not cure foundation lineage, transfer central review/security evidence, or establish real-browser acceptance. + +PR #263 adds a typed `session.unsubscribe` path for the exact opaque committed-navigation subscription receipt. Predecessor `37ae698c4a9e12d2fabf821ae5b910ea8a35ab8a` failed hosted CI because canonical rustfmt was not applied and one real `send()` frame-error arm was uncovered. Repair `3f22de94b63da83eaa8b5b1270912b21a3ecd006` changes only the unsubscribe failure integration test: it applies canonical formatting and exercises the real loopback RFC 6455 no-write `MalformedFrame` path caused by adjacent client masking-key reuse, proving no unsubscribe bytes reach the peer and only that unsubscribe correlation retires. Its fresh CI `34009256997` is queued at this snapshot; predecessor or partial evidence is not promoted. ### CI, review, and evidence control plane -Issue #279 remains the protected-main owner for exact-head documentation verification and the Ready-transition execution gap. Its current record shows workflow-free classifier PR #287 succeeding in native CI/Security/Semgrep while required CodeQL fails only after current-head scan dispatch at the central verdict handoff. That repeated CodeQL dispatch-to-verdict defect is owned by `ContextualWisdomLab/.github#712`; leaf branches must not duplicate CodeQL, weaken required checks, or convert queued/skipped/provider-incomplete evidence into GREEN. +Issue #279 remains the protected-main owner for exact-head documentation verification and the Ready-transition execution gap. The repeated CodeQL dispatch-to-verdict defect observed after successful current-head central scan dispatch is owned by `ContextualWisdomLab/.github#712`; leaf branches must not duplicate CodeQL, weaken required checks, or convert queued/skipped/provider-incomplete evidence into GREEN. Protected review/ruleset requirements remain independent from tests. Passing automation is not approval. Stale review state after a push is not current approval, and a Draft, conflicted, or stack-incomplete PR is not merge-ready merely because one repository workflow passed. ### Product and buyer gaps that remain open -The following capabilities are not treated as protected-main commercial completion merely because foundations or active PRs exist: - | Track | Current boundary | Completion evidence required | |---|---|---| | Governed browser vertical slice | WebDriver BiDi contracts and transport work are active; current stack requires foundation repair/restack | Real pinned Chromium session/navigation/semantic observation/policy-authorized interaction/post-condition/evidence/cleanup GREEN on the same exact head, then protected integration | @@ -58,8 +65,8 @@ Deterministic browser policy/security decisions remain deterministic. Model-back ### Current repair order -1. Finish #195 exact-head repository verification and repair any new RED at that exact head. -2. Reconcile the remaining inherited documentation differences content-aware; do not overwrite later WebDriver deltas with an older whole tree. +1. Resolve PR #195's live head immediately before interpreting its CI/MV3 results; repair any new RED at that exact head and keep the content-aware recovery lineage intact. +2. Reconcile any remaining inherited documentation differences content-aware; do not overwrite later WebDriver deltas with an older whole tree. 3. Reconstruct #242 and descendants from the repaired #195 foundation using ordinary forward/non-force adoption, then regenerate exact-head checks on every claimed integration point. 4. Complete #212's authorized current-generation Chromium sandbox-helper integration and rerun realistic pinned-Chromium evidence on the exact consumer head. 5. Resolve central required-verdict failures through their canonical owner (`ContextualWisdomLab/.github#712`) rather than leaf duplication or gate weakening. @@ -68,7 +75,7 @@ Deterministic browser policy/security decisions remain deterministic. Model-back ## Evidence commands -The snapshot is reproducible from GitHub without treating local branch state as authority. Paginate list endpoints before deriving counts or per-PR evidence. +The snapshot is reproducible from GitHub without treating local branch state as authority. Paginate list endpoints before deriving counts or per-PR evidence. Mutable PR heads are resolved immediately before their evidence is queried; this avoids making the baseline stale merely by committing an update to the baseline itself. ```bash set -euo pipefail @@ -79,10 +86,13 @@ gh api --paginate "repos/$repo/pulls?state=open&per_page=100" --slurp gh api --paginate "repos/$repo/issues?state=open&per_page=100" --slurp gh api "repos/$repo/releases?per_page=100" +foundation_head="$(gh api "repos/$repo/pulls/195" --jq ".head.sha")" gh api "repos/$repo/pulls/195" -gh api "repos/$repo/commits/89708cf5e474f7701513b84a1356a8ce1699bef5/check-runs?per_page=100" -gh api "repos/$repo/actions/runs?head_sha=89708cf5e474f7701513b84a1356a8ce1699bef5&per_page=100" +gh api "repos/$repo/commits/$foundation_head/check-runs?per_page=100" +gh api "repos/$repo/actions/runs?head_sha=$foundation_head&per_page=100" +gh api "repos/$repo/pulls/242" +gh api "repos/$repo/pulls/263" gh api "repos/$repo/pulls/148" gh api "repos/$repo/issues/212" gh api "repos/$repo/issues/279" From 6e5aeb583738aa3b3c3433d7f63265fac8aef8c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:02:13 +0900 Subject: [PATCH 567/570] fix(repo): restore rust toolchain update contract --- .github/dependabot.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..d331df5fd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "rust-toolchain" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 1 From c8f850707474726eb580ec2652c254e79d37bb9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:02:35 +0900 Subject: [PATCH 568/570] test(docs): match canonical proposed ADR metadata --- tests/test_product_gap_discoverability_contract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_product_gap_discoverability_contract.py b/tests/test_product_gap_discoverability_contract.py index 674bba62b..237420e1c 100644 --- a/tests/test_product_gap_discoverability_contract.py +++ b/tests/test_product_gap_discoverability_contract.py @@ -26,7 +26,7 @@ def test_bap_lifecycle_adr_is_indexed_without_premature_acceptance(self) -> None encoding="utf-8" ) index = (ROOT / "docs" / "adr" / "README.md").read_text(encoding="utf-8") - self.assertIn("- Status: Proposed", adr) + self.assertIn("- **Status:** Proposed", adr) self.assertIn("0016-bap-task-lifecycle-authority.md", index) self.assertIn("| Proposed |", index) From 7dbdf0364768f049286bc5cb59e85e9978d533ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:04:25 +0900 Subject: [PATCH 569/570] docs(gaps): preserve release contract wording --- docs/product-technical-gap-baseline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c1f040d64..4ce0fc098 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -54,7 +54,7 @@ Protected review/ruleset requirements remain independent from tests. Passing aut | MCP/agent boundary | Typed stateless MCP/core authority contracts exist; MCP remains an adapter | Released API/adapter behavior that cannot become policy authority or bypass browser post-condition verification | | Persistent task/API surface | Foundations exist | Tenant-scoped persistence, recovery, idempotency, operability, and API acceptance on protected code | | Enterprise administration | Governance primitives exist | Buyer-visible policy/approval/audit administration with purpose-bound sensitive-data handling and accessibility verification | -| Distribution and release | No GitHub Release exists | Signed cross-platform artifacts, SBOM/provenance, reproducibility, rollback, package/tag and immutable release verification | +| Distribution and release | No GitHub Release exists | signed cross-platform artifacts, SBOM/provenance, reproducibility, rollback, package/tag and immutable release verification | | CI evidence throughput | Exact-head verification exists but central verdict/queue issues remain | Reliable exact-head required workflows without gate weakening, skipped-result promotion, or stale evidence transfer | ### Bounded-context and ownership constraints From 63997bcf555e2c5c8e91ba287734ffba3837a1b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 14:08:45 +0900 Subject: [PATCH 570/570] docs(gaps): record exercised foundation workflow RED --- docs/product-technical-gap-baseline.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 4ce0fc098..c867d25a2 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -24,6 +24,8 @@ PR #195 is the earliest active owner point for the foundation recovery. The bran The baseline deliberately does not embed PR #195's mutable live head as its own current identity. Any commit that updates this file would immediately make such a literal stale. Evidence commands therefore re-resolve the PR head from GitHub before fetching checks. `tests/test_gap_snapshot_inventory_consistency.py` enforces that rule instead of pinning a self-invalidating head SHA. +Exact predecessor `7b2f30941ba2a2b17870f31a835a3ed0217be17c` exercised the next recovery RED. CI `34010603105` failed in Python repository contracts while Production coverage succeeded; MV3 `34010603003` also succeeded. Three leaf/content defects were repaired without changing workflow source: `6e5aeb583738aa3b3c3433d7f63265fac8aef8c3` restores protected `.github/dependabot.yml`, `c8f850707474726eb580ec2652c254e79d37bb9b` aligns the Proposed ADR lifecycle assertion with the canonical bold metadata, and `7dbdf0364768f049286bc5cb59e85e9978d533ca` restores the required fail-closed release wording. The remaining two REDs are both workflow-contract mismatches: this historical branch still carries an older CI concurrency/lifecycle generation and `nightly-2026-08-01`, while protected main carries repository-scoped concurrency, PR-only cancellation, explicit Draft/closed lifecycle guards, and `nightly-2026-08-18`. Issue #279 now contains the exact owner-path evidence. Scheduled product ownership does not weaken those tests or edit `.github/workflows/**` to make the stale generation pass. + PR #242 still targets the pre-recovery #195 generation `48eb2d23009c1c804520dd5efcd0d4d072aacef1` and GitHub currently reports it non-mergeable. Descendants must adopt the repaired foundation content-aware and non-destructively; preserving an old child tree in a topology-only merge would reintroduce the deleted product assets. Each reconstructed exact head needs fresh checks. No predecessor GREEN transfers. ### Browser sandbox and realistic Chromium acceptance @@ -36,11 +38,11 @@ Issue #212 is the canonical workflow-owner boundary for the missing sandbox-help The repaired teardown/navigation chain #255 → #256 → #257 → #258 → #259 → #260 → #261 → #277 has terminal repository-native success on the already-restacked exact heads. That evidence validates those exact trees only; it does not cure foundation lineage, transfer central review/security evidence, or establish real-browser acceptance. -PR #263 adds a typed `session.unsubscribe` path for the exact opaque committed-navigation subscription receipt. Predecessor `37ae698c4a9e12d2fabf821ae5b910ea8a35ab8a` failed hosted CI because canonical rustfmt was not applied and one real `send()` frame-error arm was uncovered. Repair `3f22de94b63da83eaa8b5b1270912b21a3ecd006` changes only the unsubscribe failure integration test: it applies canonical formatting and exercises the real loopback RFC 6455 no-write `MalformedFrame` path caused by adjacent client masking-key reuse, proving no unsubscribe bytes reach the peer and only that unsubscribe correlation retires. Its fresh CI `34009256997` is queued at this snapshot; predecessor or partial evidence is not promoted. +PR #263 adds a typed `session.unsubscribe` path for the exact opaque committed-navigation subscription receipt. Predecessor `37ae698c4a9e12d2fabf821ae5b910ea8a35ab8a` failed hosted CI because canonical rustfmt was not applied and one real `send()` frame-error arm was uncovered. Repair `3f22de94b63da83eaa8b5b1270912b21a3ecd006` applies canonical formatting and adds the realistic loopback RFC 6455 no-write `MalformedFrame` path caused by adjacent client masking-key reuse, proving no unsubscribe bytes reach the peer and only that unsubscribe correlation retires. Exact CI `34009256997` is terminal success: Rust contracts job `101422055630` passed Python contracts, formatting, locked workspace checks, tests, Clippy and rustdoc; Production coverage job `101422055538` passed exact coverage enforcement. This GREEN applies only to that exact tree and does not cure the separate foundation or browser-runtime prerequisites. ### CI, review, and evidence control plane -Issue #279 remains the protected-main owner for exact-head documentation verification and the Ready-transition execution gap. The repeated CodeQL dispatch-to-verdict defect observed after successful current-head central scan dispatch is owned by `ContextualWisdomLab/.github#712`; leaf branches must not duplicate CodeQL, weaken required checks, or convert queued/skipped/provider-incomplete evidence into GREEN. +Issue #279 remains the protected-main owner for exact-head documentation verification, Ready-transition execution, and the protected workflow generation that historical product stacks must preserve rather than weakening repository contracts. The repeated CodeQL dispatch-to-verdict defect observed after successful current-head central scan dispatch is owned by `ContextualWisdomLab/.github#712`; leaf branches must not duplicate CodeQL, weaken required checks, or convert queued/skipped/provider-incomplete evidence into GREEN. Protected review/ruleset requirements remain independent from tests. Passing automation is not approval. Stale review state after a push is not current approval, and a Draft, conflicted, or stack-incomplete PR is not merge-ready merely because one repository workflow passed. @@ -55,7 +57,7 @@ Protected review/ruleset requirements remain independent from tests. Passing aut | Persistent task/API surface | Foundations exist | Tenant-scoped persistence, recovery, idempotency, operability, and API acceptance on protected code | | Enterprise administration | Governance primitives exist | Buyer-visible policy/approval/audit administration with purpose-bound sensitive-data handling and accessibility verification | | Distribution and release | No GitHub Release exists | signed cross-platform artifacts, SBOM/provenance, reproducibility, rollback, package/tag and immutable release verification | -| CI evidence throughput | Exact-head verification exists but central verdict/queue issues remain | Reliable exact-head required workflows without gate weakening, skipped-result promotion, or stale evidence transfer | +| CI evidence throughput | Exact-head verification exists but central verdict/queue issues and stale inherited workflow generations remain | Reliable exact-head required workflows without gate weakening, skipped-result promotion, stale workflow adoption, or stale evidence transfer | ### Bounded-context and ownership constraints @@ -65,7 +67,7 @@ Deterministic browser policy/security decisions remain deterministic. Model-back ### Current repair order -1. Resolve PR #195's live head immediately before interpreting its CI/MV3 results; repair any new RED at that exact head and keep the content-aware recovery lineage intact. +1. Resolve PR #195's live head immediately before interpreting its CI/MV3 results. Preserve the three completed content repairs and have the authorized workflow owner adopt the protected current CI generation rather than weakening the restored repository contracts. 2. Reconcile any remaining inherited documentation differences content-aware; do not overwrite later WebDriver deltas with an older whole tree. 3. Reconstruct #242 and descendants from the repaired #195 foundation using ordinary forward/non-force adoption, then regenerate exact-head checks on every claimed integration point. 4. Complete #212's authorized current-generation Chromium sandbox-helper integration and rerun realistic pinned-Chromium evidence on the exact consumer head.