diff --git a/Cargo.lock b/Cargo.lock index 848cb7320..4e01dfe1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -288,6 +288,13 @@ dependencies = [ "originweave-core", ] +[[package]] +name = "originweave-extension" +version = "0.1.0" +dependencies = [ + "originweave-core", +] + [[package]] name = "originweave-network" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 0d5ab469c..7c7a78e49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/originweave-core", + "crates/originweave-extension", "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-resource", diff --git a/crates/originweave-core/src/root.rs b/crates/originweave-core/src/root.rs index 9c3018431..60d659105 100644 --- a/crates/originweave-core/src/root.rs +++ b/crates/originweave-core/src/root.rs @@ -15,5 +15,6 @@ pub use contracts::*; pub mod mcp; mod native_messaging; pub use native_messaging::*; + /// Deterministic fail-closed release benchmark acceptance aggregation. pub mod release_acceptance; diff --git a/crates/originweave-extension/Cargo.toml b/crates/originweave-extension/Cargo.toml new file mode 100644 index 000000000..fed097c52 --- /dev/null +++ b/crates/originweave-extension/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "originweave-extension" +description = "OriginWeave extension and native-host policy contracts." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +publish = false + +[lib] +path = "src/root.rs" + +[dependencies] +originweave-core = { path = "../originweave-core" } + +[lints] +workspace = true diff --git a/crates/originweave-extension/src/native_messaging_manifest.rs b/crates/originweave-extension/src/native_messaging_manifest.rs new file mode 100644 index 000000000..ca6606dc6 --- /dev/null +++ b/crates/originweave-extension/src/native_messaging_manifest.rs @@ -0,0 +1,271 @@ +//! Deterministic authority extracted from one validated Chrome native-messaging host manifest. +//! +//! This module validates caller-supplied manifest fields only. It does not prove that a +//! manifest is installed, that an executable path is owned by a trusted principal, or that +//! any process attached to stdio is the host named by the manifest. Runtime adapters must +//! establish those boundaries independently before composing this evidence with process +//! authority. + +use std::collections::BTreeSet; +use std::fmt; + +use crate::{ExtensionId, NativeMessagingAccessRequest, NativeMessagingHostName}; + +/// Maximum number of raw `allowed_origins` entries accepted from one host manifest. +/// +/// Chrome does not define this OriginWeave-specific safety budget. The limit bounds work +/// before duplicate origins are collapsed and therefore prevents a syntactically valid +/// manifest from turning policy admission into unbounded allocation or comparison work. +pub const MAX_NATIVE_MESSAGING_ALLOWED_ORIGINS: usize = 256; + +/// Maximum UTF-8 byte length accepted for one declared native-host executable path. +/// +/// This 32 KiB value is an OriginWeave allocation safety budget, not a Chrome or operating- +/// system path-validity limit. Runtime adapters remain responsible for platform-native path +/// resolution, canonicalization, ownership, and executable identity checks. +pub const MAX_NATIVE_MESSAGING_EXECUTABLE_PATH_BYTES: usize = 32 * 1024; + +/// Operating-system path semantics used by one native-messaging host manifest. +/// +/// Chrome requires absolute native-host paths on Linux and macOS, while Windows also allows +/// paths relative to the manifest directory. OriginWeave records the platform explicitly so +/// later runtime adapters cannot reinterpret a validated path under different semantics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeMessagingHostPlatform { + /// Linux native-messaging host-manifest semantics. + Linux, + /// macOS native-messaging host-manifest semantics. + MacOs, + /// Windows native-messaging host-manifest semantics. + Windows, +} + +/// Validated authority-bearing fields from one Chrome native-messaging host manifest. +/// +/// The record contains the exact host identity, declared executable-path text and platform, +/// exact Chromium extension identities named by the manifest's `allowed_origins`, and whether +/// the manifest explicitly declares support for native-initiated connections. Possessing this +/// value is not proof of manifest installation, path canonicalization, executable existence or +/// ownership, process identity, message provenance, Chrome feature/policy enablement, or Agent +/// authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeMessagingHostManifest { + host_name: NativeMessagingHostName, + platform: NativeMessagingHostPlatform, + executable_path: String, + allowed_extensions: BTreeSet, + supports_native_initiated_connections: bool, +} + +impl NativeMessagingHostManifest { + /// Validate the authority-bearing host-manifest fields without widening them. + /// + /// This compatibility constructor records no native-initiated-connection declaration. + /// Call [`Self::parse_with_native_initiated_connections`] only when a trusted structured + /// parser has explicitly validated that optional manifest field. + pub fn parse( + host_name: NativeMessagingHostName, + platform: NativeMessagingHostPlatform, + executable_path: &str, + interface_type: &str, + allowed_origins: &[&str], + ) -> Result { + Self::parse_with_native_initiated_connections( + host_name, + platform, + executable_path, + interface_type, + false, + allowed_origins, + ) + } + + /// Validate authority-bearing host-manifest fields plus the optional native-initiation flag. + /// + /// `interface_type` must be exactly `stdio`. Linux and macOS executable paths must be + /// absolute, matching Chrome's native-messaging contract; Windows relative paths remain + /// relative and must be resolved by a trusted runtime adapter against the authenticated + /// manifest directory. Empty paths, embedded NUL bytes, and paths exceeding the + /// OriginWeave allocation budget are rejected before storage on every platform. Every + /// allowed origin must be exactly `chrome-extension:///`; + /// alternate schemes, wildcards, suffix paths, query strings, fragments, and + /// non-canonical extension identities are rejected rather than normalized. The raw list + /// is bounded before deduplication. + /// + /// `supports_native_initiated_connections` records only the validated manifest declaration. + /// It does not prove that Chromium enables the corresponding feature, that policy permits + /// it, that a process is the declared host, or that any native-initiated request has Agent + /// authority. + pub fn parse_with_native_initiated_connections( + host_name: NativeMessagingHostName, + platform: NativeMessagingHostPlatform, + executable_path: &str, + interface_type: &str, + supports_native_initiated_connections: bool, + allowed_origins: &[&str], + ) -> Result { + if interface_type != "stdio" { + return Err(NativeMessagingHostManifestError::UnsupportedInterfaceType); + } + validate_executable_path(platform, executable_path)?; + if allowed_origins.is_empty() { + return Err(NativeMessagingHostManifestError::MissingAllowedOrigin); + } + if allowed_origins.len() > MAX_NATIVE_MESSAGING_ALLOWED_ORIGINS { + return Err(NativeMessagingHostManifestError::TooManyAllowedOrigins); + } + + let mut allowed_extensions = BTreeSet::new(); + for origin in allowed_origins { + allowed_extensions.insert(parse_extension_origin(origin)?); + } + + Ok(Self { + host_name, + platform, + executable_path: executable_path.to_owned(), + allowed_extensions, + supports_native_initiated_connections, + }) + } + + /// Return the exact native-messaging host identity declared by the manifest. + #[must_use] + pub const fn host_name(&self) -> &NativeMessagingHostName { + &self.host_name + } + + /// Return the platform whose path semantics were used to validate the manifest. + #[must_use] + pub const fn platform(&self) -> NativeMessagingHostPlatform { + self.platform + } + + /// Return the exact executable-path text declared by the manifest. + /// + /// Windows relative paths are intentionally not resolved here because safe resolution + /// requires the authenticated manifest location. The returned path therefore carries no + /// filesystem-existence, canonicalization, ownership, or process-identity claim. + #[must_use] + pub fn executable_path(&self) -> &str { + &self.executable_path + } + + /// Return the number of distinct exact extension identities explicitly allowed. + #[must_use] + pub fn allowed_extension_count(&self) -> usize { + self.allowed_extensions.len() + } + + /// Return whether the validated manifest explicitly declares native-initiated connections. + /// + /// A `true` value is declaration evidence only. It does not prove Chromium feature or + /// enterprise-policy enablement and grants no connection, process, message, or Agent + /// authority by itself. + #[must_use] + pub const fn supports_native_initiated_connections(&self) -> bool { + self.supports_native_initiated_connections + } + + /// Evaluate one native-messaging request against this exact manifest authority. + /// + /// Host identity is checked before extension membership. An `Allow` result means only + /// that the already-validated manifest fields name the exact request; it does not mint + /// Agent authority or attest the installed host process. + #[must_use] + pub fn evaluate( + &self, + request: &NativeMessagingAccessRequest, + ) -> NativeMessagingHostManifestAccessDecision { + if request.host_name() != &self.host_name { + return NativeMessagingHostManifestAccessDecision::DenyHostMismatch; + } + if !self.allowed_extensions.contains(request.extension_id()) { + return NativeMessagingHostManifestAccessDecision::DenyExtensionNotAllowed; + } + NativeMessagingHostManifestAccessDecision::Allow + } +} + +fn validate_executable_path( + platform: NativeMessagingHostPlatform, + executable_path: &str, +) -> Result<(), NativeMessagingHostManifestError> { + if executable_path.is_empty() || executable_path.contains('\0') { + return Err(NativeMessagingHostManifestError::InvalidExecutablePath); + } + if executable_path.len() > MAX_NATIVE_MESSAGING_EXECUTABLE_PATH_BYTES { + return Err(NativeMessagingHostManifestError::ExecutablePathTooLong); + } + if platform != NativeMessagingHostPlatform::Windows && !executable_path.starts_with('/') { + return Err(NativeMessagingHostManifestError::RelativeExecutablePathUnsupported); + } + Ok(()) +} + +fn parse_extension_origin(origin: &str) -> Result { + let Some(extension_text) = origin.strip_prefix("chrome-extension://") else { + return Err(NativeMessagingHostManifestError::InvalidAllowedOrigin); + }; + let Some(extension_text) = extension_text.strip_suffix('/') else { + return Err(NativeMessagingHostManifestError::InvalidAllowedOrigin); + }; + ExtensionId::parse(extension_text) + .map_err(|_error| NativeMessagingHostManifestError::InvalidAllowedOrigin) +} + +/// Result of matching one native-messaging request to validated host-manifest authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeMessagingHostManifestAccessDecision { + /// The manifest names the exact requested host and explicitly allows the extension. + Allow, + /// The request names a different host from the validated manifest. + DenyHostMismatch, + /// The exact requesting extension is absent from the manifest allow-list. + DenyExtensionNotAllowed, +} + +/// Failure to validate authority-bearing fields from a native-messaging host manifest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeMessagingHostManifestError { + /// The manifest interface type was not exactly Chrome's `stdio` value. + UnsupportedInterfaceType, + /// The manifest executable-path text was empty or contained an embedded NUL byte. + InvalidExecutablePath, + /// The manifest executable-path text exceeded the OriginWeave allocation safety budget. + ExecutablePathTooLong, + /// A non-Windows manifest used a relative executable path. + RelativeExecutablePathUnsupported, + /// The manifest did not explicitly allow any extension origin. + MissingAllowedOrigin, + /// The raw allowed-origin list exceeded the OriginWeave admission safety budget. + TooManyAllowedOrigins, + /// An allowed origin was not one exact canonical Chromium extension origin. + InvalidAllowedOrigin, +} + +impl fmt::Display for NativeMessagingHostManifestError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedInterfaceType => formatter + .write_str("native messaging host manifest interface type must be stdio"), + Self::InvalidExecutablePath => formatter + .write_str("native messaging host manifest contains an invalid executable path"), + Self::ExecutablePathTooLong => formatter.write_str( + "native messaging host manifest executable path exceeds the OriginWeave safety budget", + ), + Self::RelativeExecutablePathUnsupported => formatter + .write_str("native messaging host executable path must be absolute on this platform"), + Self::MissingAllowedOrigin => formatter.write_str( + "native messaging host manifest must allow at least one exact extension origin", + ), + Self::TooManyAllowedOrigins => formatter.write_str( + "native messaging host manifest exceeds the OriginWeave allowed-origin safety budget", + ), + Self::InvalidAllowedOrigin => formatter + .write_str("native messaging host manifest contains an invalid extension origin"), + } + } +} + +impl std::error::Error for NativeMessagingHostManifestError {} diff --git a/crates/originweave-extension/src/native_messaging_manifest_document.rs b/crates/originweave-extension/src/native_messaging_manifest_document.rs new file mode 100644 index 000000000..97dc0bb0e --- /dev/null +++ b/crates/originweave-extension/src/native_messaging_manifest_document.rs @@ -0,0 +1,599 @@ +//! Bounded parsing for one Chrome native-messaging host manifest document. +//! +//! This module bounds untrusted document bytes before allocation, validates complete JSON syntax +//! for the reviewed native-host schema, and then delegates authority-bearing field validation to +//! [`NativeMessagingHostManifest`]. Parsing a document does not prove that the manifest is +//! installed by Chrome, authenticated by the operating system, or safe to use as process or Agent +//! authority. + +use std::fmt; + +use crate::{ + MAX_NATIVE_MESSAGING_ALLOWED_ORIGINS, NativeMessagingHostManifest, + NativeMessagingHostManifestError, NativeMessagingHostName, NativeMessagingHostNameError, + NativeMessagingHostPlatform, +}; + +/// Maximum UTF-8 byte length accepted for one native-messaging host manifest document. +/// +/// Chrome does not define this OriginWeave-specific 64 KiB safety budget. The limit exists to +/// bound allocation and parser input before any JSON or authority-bearing field processing. +pub const MAX_NATIVE_MESSAGING_MANIFEST_DOCUMENT_BYTES: usize = 64 * 1024; + +/// A bounded UTF-8 native-messaging host manifest document awaiting structured parsing. +/// +/// Possessing this value proves only that the original byte document was non-empty, within the +/// OriginWeave ingress budget, valid UTF-8, and wrapped by one outer object boundary after JSON +/// whitespace is ignored. Call [`Self::parse_host_manifest`] to establish complete JSON/schema +/// validity and the existing host-manifest authority contract. This value alone carries no +/// installation, origin, executable, process, or Agent authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeMessagingManifestDocument { + text: String, +} + +impl NativeMessagingManifestDocument { + /// Admit one untrusted manifest document before structured JSON parsing. + /// + /// The byte-size check runs before UTF-8 decoding or allocation of the stored `String` so + /// oversized input cannot force unbounded parser or text-storage work. Empty input, invalid + /// UTF-8, and documents whose first and last non-JSON-whitespace characters are not `{` and + /// `}` fail closed. The object-envelope check is only a cheap ingress guard; + /// [`Self::parse_host_manifest`] must still prove complete JSON syntax and field semantics. + pub fn parse(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(NativeMessagingManifestDocumentError::EmptyDocument); + } + if bytes.len() > MAX_NATIVE_MESSAGING_MANIFEST_DOCUMENT_BYTES { + return Err(NativeMessagingManifestDocumentError::DocumentTooLarge); + } + let text = std::str::from_utf8(bytes) + .map_err(|_error| NativeMessagingManifestDocumentError::InvalidUtf8)?; + let trimmed = text.trim_matches(|character| matches!(character, ' ' | '\t' | '\r' | '\n')); + if !trimmed.starts_with('{') || !trimmed.ends_with('}') { + return Err(NativeMessagingManifestDocumentError::InvalidObjectBoundary); + } + Ok(Self { + text: text.to_owned(), + }) + } + + /// Parse the complete reviewed Chrome native-host manifest schema and validate its authority. + /// + /// The parser accepts exactly the required `name`, `description`, `path`, `type`, and + /// `allowed_origins` members plus the optional boolean + /// `supports_native_initiated_connections`. Duplicate decoded member names, unknown members, + /// missing required members, wrong JSON types, malformed escapes, malformed arrays, trailing + /// commas, and trailing JSON data all fail closed. JSON strings are decoded before the + /// existing host-name, path, interface, and extension-origin validators run. + /// + /// `description` is required, type-checked, and non-empty because Chrome's manifest schema + /// requires a non-empty description, but it is intentionally not retained as authority. The + /// optional native-initiated field defaults to `false` when absent. A successful result still + /// does not prove installation, filesystem ownership, executable identity, process provenance, + /// feature/policy enablement, message provenance, or Agent authority. + pub fn parse_host_manifest( + &self, + platform: NativeMessagingHostPlatform, + ) -> Result { + let fields = ManifestJsonParser::new(self).parse_manifest()?; + let host_name = NativeMessagingHostName::parse(&fields.name) + .map_err(NativeMessagingManifestParseError::HostName)?; + let allowed_origins: Vec<&str> = + fields.allowed_origins.iter().map(String::as_str).collect(); + NativeMessagingHostManifest::parse_with_native_initiated_connections( + host_name, + platform, + &fields.executable_path, + &fields.interface_type, + fields.supports_native_initiated_connections, + &allowed_origins, + ) + .map_err(NativeMessagingManifestParseError::Manifest) + } + + /// Return the exact validated UTF-8 text without interpreting JSON fields. + #[must_use] + pub fn as_str(&self) -> &str { + &self.text + } +} + +#[derive(Debug, PartialEq, Eq)] +struct ManifestFields { + name: String, + executable_path: String, + interface_type: String, + allowed_origins: Vec, + supports_native_initiated_connections: bool, +} + +#[derive(Debug, Default)] +struct PartialManifestFields { + name: Option, + description: Option, + executable_path: Option, + interface_type: Option, + allowed_origins: Option>, + supports_native_initiated_connections: Option, +} + +impl PartialManifestFields { + fn finish(self) -> Result { + let ( + Some(name), + Some(description), + Some(executable_path), + Some(interface_type), + Some(allowed_origins), + ) = ( + self.name, + self.description, + self.executable_path, + self.interface_type, + self.allowed_origins, + ) + else { + return Err(NativeMessagingManifestParseError::MissingRequiredField); + }; + if description.is_empty() { + return Err(NativeMessagingManifestParseError::InvalidFieldValue); + } + Ok(ManifestFields { + name, + executable_path, + interface_type, + allowed_origins, + supports_native_initiated_connections: self + .supports_native_initiated_connections + .unwrap_or(false), + }) + } +} + +struct ManifestJsonParser<'a> { + input: &'a str, + position: usize, +} + +fn decoded_json_string(bytes: Vec) -> Result { + String::from_utf8(bytes).map_err(|_error| NativeMessagingManifestParseError::InvalidJson) +} + +impl<'a> ManifestJsonParser<'a> { + fn new(document: &'a NativeMessagingManifestDocument) -> Self { + Self { + input: &document.text, + position: 0, + } + } + + fn parse_manifest(mut self) -> Result { + self.skip_whitespace(); + // The document constructor already proved that the first non-whitespace byte is `{`. + self.position += 1; + self.skip_whitespace(); + let mut fields = PartialManifestFields::default(); + if self.peek_byte() == Some(b'}') { + self.position += 1; + } else { + loop { + let key = self.parse_string()?; + self.skip_whitespace(); + self.expect_byte(b':')?; + self.skip_whitespace(); + self.parse_field(&key, &mut fields)?; + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + if self.peek_byte() == Some(b'}') { + return Err(NativeMessagingManifestParseError::InvalidJson); + } + } + Some(b'}') => { + self.position += 1; + break; + } + _ => return Err(NativeMessagingManifestParseError::InvalidJson), + } + } + } + self.skip_whitespace(); + if self.position != self.input.len() { + return Err(NativeMessagingManifestParseError::InvalidJson); + } + fields.finish() + } + + fn parse_field( + &mut self, + key: &str, + fields: &mut PartialManifestFields, + ) -> Result<(), NativeMessagingManifestParseError> { + match key { + "name" => { + if fields.name.is_some() { + return Err(NativeMessagingManifestParseError::DuplicateField); + } + fields.name = Some(self.parse_typed_string()?); + } + "description" => { + if fields.description.is_some() { + return Err(NativeMessagingManifestParseError::DuplicateField); + } + fields.description = Some(self.parse_typed_string()?); + } + "path" => { + if fields.executable_path.is_some() { + return Err(NativeMessagingManifestParseError::DuplicateField); + } + fields.executable_path = Some(self.parse_typed_string()?); + } + "type" => { + if fields.interface_type.is_some() { + return Err(NativeMessagingManifestParseError::DuplicateField); + } + fields.interface_type = Some(self.parse_typed_string()?); + } + "allowed_origins" => { + if fields.allowed_origins.is_some() { + return Err(NativeMessagingManifestParseError::DuplicateField); + } + fields.allowed_origins = Some(self.parse_string_array()?); + } + "supports_native_initiated_connections" => { + if fields.supports_native_initiated_connections.is_some() { + return Err(NativeMessagingManifestParseError::DuplicateField); + } + fields.supports_native_initiated_connections = Some(self.parse_boolean()?); + } + _ => return Err(NativeMessagingManifestParseError::UnknownField), + } + Ok(()) + } + + fn parse_typed_string(&mut self) -> Result { + if self.peek_byte() != Some(b'"') { + return Err(NativeMessagingManifestParseError::InvalidFieldType); + } + self.parse_string() + } + + fn parse_string_array(&mut self) -> Result, NativeMessagingManifestParseError> { + if self.peek_byte() != Some(b'[') { + return Err(NativeMessagingManifestParseError::InvalidFieldType); + } + self.position += 1; + self.skip_whitespace(); + let mut values = Vec::new(); + if self.peek_byte() == Some(b']') { + self.position += 1; + return Ok(values); + } + loop { + if values.len() == MAX_NATIVE_MESSAGING_ALLOWED_ORIGINS { + return Err(NativeMessagingManifestParseError::Manifest( + NativeMessagingHostManifestError::TooManyAllowedOrigins, + )); + } + if self.peek_byte() != Some(b'"') { + return Err(NativeMessagingManifestParseError::InvalidFieldType); + } + values.push(self.parse_string()?); + self.skip_whitespace(); + match self.peek_byte() { + Some(b',') => { + self.position += 1; + self.skip_whitespace(); + if self.peek_byte() == Some(b']') { + return Err(NativeMessagingManifestParseError::InvalidJson); + } + } + Some(b']') => { + self.position += 1; + return Ok(values); + } + _ => return Err(NativeMessagingManifestParseError::InvalidJson), + } + } + } + + fn parse_boolean(&mut self) -> Result { + if self.input[self.position..].starts_with("true") { + self.position += 4; + return Ok(true); + } + if self.input[self.position..].starts_with("false") { + self.position += 5; + return Ok(false); + } + Err(NativeMessagingManifestParseError::InvalidFieldType) + } + + fn parse_string(&mut self) -> Result { + self.expect_byte(b'"')?; + let mut output = Vec::new(); + loop { + let Some(byte) = self.peek_byte() else { + return Err(NativeMessagingManifestParseError::InvalidJson); + }; + match byte { + b'"' => { + self.position += 1; + return decoded_json_string(output); + } + b'\\' => { + self.position += 1; + self.parse_escape(&mut output)?; + } + 0x00..=0x1f => return Err(NativeMessagingManifestParseError::InvalidJson), + _ => { + output.push(byte); + self.position += 1; + } + } + } + } + + fn parse_escape( + &mut self, + output: &mut Vec, + ) -> Result<(), NativeMessagingManifestParseError> { + // NUL is not a legal JSON escape, so unexpected EOF shares the normal fail-closed path. + let escape = self.take_byte().unwrap_or(b'\0'); + match escape { + b'"' => output.push(b'"'), + b'\\' => output.push(b'\\'), + b'/' => output.push(b'/'), + b'b' => output.push(0x08), + b'f' => output.push(0x0c), + b'n' => output.push(b'\n'), + b'r' => output.push(b'\r'), + b't' => output.push(b'\t'), + b'u' => { + let character = self.parse_unicode_escape()?; + let mut encoded = [0_u8; 4]; + output.extend_from_slice(character.encode_utf8(&mut encoded).as_bytes()); + } + _ => return Err(NativeMessagingManifestParseError::InvalidJson), + } + Ok(()) + } + + fn parse_unicode_escape(&mut self) -> Result { + let first = self.parse_hex_quad()?; + let scalar = if (0xd800..=0xdbff).contains(&first) { + if self.take_byte() != Some(b'\\') || self.take_byte() != Some(b'u') { + return Err(NativeMessagingManifestParseError::InvalidJson); + } + let second = self.parse_hex_quad()?; + if !(0xdc00..=0xdfff).contains(&second) { + return Err(NativeMessagingManifestParseError::InvalidJson); + } + 0x1_0000 + ((u32::from(first) - 0xd800) << 10) + (u32::from(second) - 0xdc00) + } else { + if (0xdc00..=0xdfff).contains(&first) { + return Err(NativeMessagingManifestParseError::InvalidJson); + } + u32::from(first) + }; + char::from_u32(scalar).ok_or(NativeMessagingManifestParseError::InvalidJson) + } + + fn parse_hex_quad(&mut self) -> Result { + if self.position + 4 > self.input.len() { + return Err(NativeMessagingManifestParseError::InvalidJson); + } + let mut value = 0_u16; + for _ in 0..4 { + let byte = self.input.as_bytes()[self.position]; + let Some(digit) = (byte as char).to_digit(16) else { + return Err(NativeMessagingManifestParseError::InvalidJson); + }; + value = (value << 4) | digit as u16; + self.position += 1; + } + Ok(value) + } + + 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<(), NativeMessagingManifestParseError> { + if self.take_byte() == Some(expected) { + Ok(()) + } else { + Err(NativeMessagingManifestParseError::InvalidJson) + } + } + + fn peek_byte(&self) -> Option { + self.input.as_bytes().get(self.position).copied() + } + + fn take_byte(&mut self) -> Option { + let byte = self.peek_byte()?; + self.position += 1; + Some(byte) + } +} + +/// Failure to admit a native-messaging host manifest document at the pre-parser boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeMessagingManifestDocumentError { + /// The manifest document contained zero bytes. + EmptyDocument, + /// The manifest document exceeded the OriginWeave pre-parser safety budget. + DocumentTooLarge, + /// The manifest document was not valid UTF-8. + InvalidUtf8, + /// The document did not have one outer object boundary after JSON whitespace was removed. + InvalidObjectBoundary, +} + +impl fmt::Display for NativeMessagingManifestDocumentError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyDocument => { + formatter.write_str("native messaging host manifest document is empty") + } + Self::DocumentTooLarge => formatter.write_str( + "native messaging host manifest document exceeds the OriginWeave safety budget", + ), + Self::InvalidUtf8 => { + formatter.write_str("native messaging host manifest document is not valid UTF-8") + } + Self::InvalidObjectBoundary => formatter.write_str( + "native messaging host manifest document must have one outer JSON object boundary", + ), + } + } +} + +impl std::error::Error for NativeMessagingManifestDocumentError {} + +/// Failure to parse or validate a complete bounded native-messaging host manifest document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NativeMessagingManifestParseError { + /// The document was not one complete valid JSON object in the reviewed schema. + InvalidJson, + /// A decoded manifest member appeared more than once. + DuplicateField, + /// The manifest contained a member outside the reviewed Chrome native-host schema. + UnknownField, + /// One or more Chrome-required manifest members were absent. + MissingRequiredField, + /// A reviewed member used a JSON type different from the Chrome manifest contract. + InvalidFieldType, + /// A reviewed member used a JSON value rejected by the Chrome manifest contract. + InvalidFieldValue, + /// The decoded host-name string violated the existing exact host-identity contract. + HostName(NativeMessagingHostNameError), + /// The decoded authority-bearing fields failed the existing host-manifest validator. + Manifest(NativeMessagingHostManifestError), +} + +impl fmt::Display for NativeMessagingManifestParseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidJson => { + formatter.write_str("native messaging host manifest JSON is invalid") + } + Self::DuplicateField => { + formatter.write_str("native messaging host manifest contains a duplicate field") + } + Self::UnknownField => { + formatter.write_str("native messaging host manifest contains an unknown field") + } + Self::MissingRequiredField => { + formatter.write_str("native messaging host manifest is missing a required field") + } + Self::InvalidFieldType => { + formatter.write_str("native messaging host manifest field has an invalid JSON type") + } + Self::InvalidFieldValue => { + formatter.write_str("native messaging host manifest field has an invalid value") + } + Self::HostName(error) => { + write!(formatter, "invalid native messaging host name: {error}") + } + Self::Manifest(error) => { + write!(formatter, "invalid native messaging host manifest: {error}") + } + } + } +} + +impl std::error::Error for NativeMessagingManifestParseError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::HostName(error) => Some(error), + Self::Manifest(error) => Some(error), + Self::InvalidJson + | Self::DuplicateField + | Self::UnknownField + | Self::MissingRequiredField + | Self::InvalidFieldType + | Self::InvalidFieldValue => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const INVALID_JSON: NativeMessagingManifestParseError = + NativeMessagingManifestParseError::InvalidJson; + + fn parse_manifest_for_test( + raw: &str, + ) -> Result { + let Ok(document) = NativeMessagingManifestDocument::parse(raw.as_bytes()) else { + return Err(INVALID_JSON); + }; + ManifestJsonParser::new(&document).parse_manifest() + } + + #[test] + fn parser_propagates_structural_and_typed_field_failures() { + for raw in [ + "", + "{?}", + r#"{"name" "value"}"#, + r#"{"path":"\q"}"#, + r#"{"type":"\q"}"#, + ] { + assert_eq!(parse_manifest_for_test(raw), Err(INVALID_JSON)); + } + + assert_eq!( + parse_manifest_for_test(r#"{"name":1}"#), + Err(NativeMessagingManifestParseError::InvalidFieldType) + ); + } + + #[test] + fn parser_propagates_array_escape_unicode_and_byte_boundary_failures() { + assert_eq!( + parse_manifest_for_test(r#"{"allowed_origins":["\q"]}"#), + Err(INVALID_JSON) + ); + assert_eq!( + parse_manifest_for_test(r#"{"allowed_origins":["origin",]}"#), + Err(INVALID_JSON) + ); + + let mut trailing_array = ManifestJsonParser { + input: r#"["origin",]"#, + position: 0, + }; + assert_eq!(trailing_array.parse_string_array(), Err(INVALID_JSON)); + + let mut empty_escape = ManifestJsonParser { + input: "", + position: 0, + }; + let mut output = Vec::new(); + assert_eq!(empty_escape.parse_escape(&mut output), Err(INVALID_JSON)); + + let mut short_quad = ManifestJsonParser { + input: "12", + position: 0, + }; + assert_eq!(short_quad.parse_hex_quad(), Err(INVALID_JSON)); + + let mut short_second_quad = ManifestJsonParser { + input: "D83D\\u12", + position: 0, + }; + assert_eq!(short_second_quad.parse_unicode_escape(), Err(INVALID_JSON)); + + assert_eq!(decoded_json_string(vec![0xff]), Err(INVALID_JSON)); + } +} diff --git a/crates/originweave-extension/src/root.rs b/crates/originweave-extension/src/root.rs new file mode 100644 index 000000000..a6989ec86 --- /dev/null +++ b/crates/originweave-extension/src/root.rs @@ -0,0 +1,26 @@ +//! Extension-policy contracts for OriginWeave's Chromium compatibility boundary. +//! +//! This crate owns validated native-messaging host-manifest semantics. Stable identity and +//! request value objects remain in `originweave-core`; this context depends inward on those +//! contracts and does not grant process, browser-action, secret, or Agent authority. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +pub use originweave_core::{ + ExtensionId, NativeMessagingAccessRequest, NativeMessagingHostName, + NativeMessagingHostNameError, +}; + +mod native_messaging_manifest; +pub use native_messaging_manifest::{ + MAX_NATIVE_MESSAGING_ALLOWED_ORIGINS, MAX_NATIVE_MESSAGING_EXECUTABLE_PATH_BYTES, + NativeMessagingHostManifest, NativeMessagingHostManifestAccessDecision, + NativeMessagingHostManifestError, NativeMessagingHostPlatform, +}; + +mod native_messaging_manifest_document; +pub use native_messaging_manifest_document::{ + MAX_NATIVE_MESSAGING_MANIFEST_DOCUMENT_BYTES, NativeMessagingManifestDocument, + NativeMessagingManifestDocumentError, NativeMessagingManifestParseError, +}; diff --git a/crates/originweave-extension/tests/native_messaging_manifest_authority.rs b/crates/originweave-extension/tests/native_messaging_manifest_authority.rs new file mode 100644 index 000000000..7df3f4025 --- /dev/null +++ b/crates/originweave-extension/tests/native_messaging_manifest_authority.rs @@ -0,0 +1,275 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_extension::{ + ExtensionId, MAX_NATIVE_MESSAGING_ALLOWED_ORIGINS, MAX_NATIVE_MESSAGING_EXECUTABLE_PATH_BYTES, + NativeMessagingAccessRequest, NativeMessagingHostManifest, + NativeMessagingHostManifestAccessDecision, NativeMessagingHostManifestError, + NativeMessagingHostName, NativeMessagingHostPlatform, +}; + +const ALLOWED_EXTENSION: &str = "abcdefghijklmnopabcdefghijklmnop"; +const OTHER_EXTENSION: &str = "bcdefghijklmnopabcdefghijklmnopa"; +const LINUX_HOST_PATH: &str = "/opt/originweave/native-host"; + +fn extension_id(value: &str) -> ExtensionId { + ExtensionId::parse(value).expect("valid extension id") +} + +fn host_name(value: &str) -> NativeMessagingHostName { + NativeMessagingHostName::parse(value).expect("valid native messaging host name") +} + +fn extension_origin(value: &str) -> String { + format!("chrome-extension://{value}/") +} + +#[test] +fn manifest_binds_stdio_host_path_and_exact_allowed_extension_origins() -> Result<(), Box> +{ + let host = host_name("com.contextualwisdom.originweave"); + let allowed_origin = extension_origin(ALLOWED_EXTENSION); + let manifest = NativeMessagingHostManifest::parse( + host.clone(), + NativeMessagingHostPlatform::Linux, + LINUX_HOST_PATH, + "stdio", + &[allowed_origin.as_str(), allowed_origin.as_str()], + )?; + + assert_eq!(manifest.host_name(), &host); + assert_eq!(manifest.platform(), NativeMessagingHostPlatform::Linux); + assert_eq!(manifest.executable_path(), LINUX_HOST_PATH); + assert_eq!(manifest.allowed_extension_count(), 1); + + let exact = NativeMessagingAccessRequest::new(extension_id(ALLOWED_EXTENSION), host.clone()); + assert_eq!( + manifest.evaluate(&exact), + NativeMessagingHostManifestAccessDecision::Allow + ); + + let wrong_host = NativeMessagingAccessRequest::new( + extension_id(ALLOWED_EXTENSION), + host_name("com.contextualwisdom.other_host"), + ); + assert_eq!( + manifest.evaluate(&wrong_host), + NativeMessagingHostManifestAccessDecision::DenyHostMismatch + ); + + let wrong_extension = + NativeMessagingAccessRequest::new(extension_id(OTHER_EXTENSION), host.clone()); + assert_eq!( + manifest.evaluate(&wrong_extension), + NativeMessagingHostManifestAccessDecision::DenyExtensionNotAllowed + ); + Ok(()) +} + +#[test] +fn manifest_records_native_initiated_connection_declaration_without_granting_it() +-> Result<(), Box> { + let host = host_name("com.contextualwisdom.originweave"); + let allowed_origin = extension_origin(ALLOWED_EXTENSION); + + let declared = NativeMessagingHostManifest::parse_with_native_initiated_connections( + host.clone(), + NativeMessagingHostPlatform::Linux, + LINUX_HOST_PATH, + "stdio", + true, + &[allowed_origin.as_str()], + )?; + assert!(declared.supports_native_initiated_connections()); + + let absent = NativeMessagingHostManifest::parse( + host, + NativeMessagingHostPlatform::Linux, + LINUX_HOST_PATH, + "stdio", + &[allowed_origin.as_str()], + )?; + assert!(!absent.supports_native_initiated_connections()); + Ok(()) +} + +#[test] +fn manifest_enforces_platform_specific_executable_path_shape() -> Result<(), Box> { + let host = host_name("com.contextualwisdom.originweave"); + let allowed_origin = extension_origin(ALLOWED_EXTENSION); + + let windows = NativeMessagingHostManifest::parse( + host.clone(), + NativeMessagingHostPlatform::Windows, + "native-host.exe", + "stdio", + &[allowed_origin.as_str()], + )?; + assert_eq!(windows.platform(), NativeMessagingHostPlatform::Windows); + assert_eq!(windows.executable_path(), "native-host.exe"); + + for platform in [ + NativeMessagingHostPlatform::Linux, + NativeMessagingHostPlatform::MacOs, + ] { + assert_eq!( + NativeMessagingHostManifest::parse( + host.clone(), + platform, + "relative/native-host", + "stdio", + &[allowed_origin.as_str()], + ), + Err(NativeMessagingHostManifestError::RelativeExecutablePathUnsupported) + ); + } + + for invalid_path in ["", "bad\0path"] { + assert_eq!( + NativeMessagingHostManifest::parse( + host.clone(), + NativeMessagingHostPlatform::Windows, + invalid_path, + "stdio", + &[allowed_origin.as_str()], + ), + Err(NativeMessagingHostManifestError::InvalidExecutablePath) + ); + } + Ok(()) +} + +#[test] +fn manifest_bounds_executable_path_before_storage() -> Result<(), Box> { + let host = host_name("com.contextualwisdom.originweave"); + let allowed_origin = extension_origin(ALLOWED_EXTENSION); + let exact_limit = "a".repeat(MAX_NATIVE_MESSAGING_EXECUTABLE_PATH_BYTES); + let one_over = "a".repeat(MAX_NATIVE_MESSAGING_EXECUTABLE_PATH_BYTES + 1); + + let accepted = NativeMessagingHostManifest::parse( + host.clone(), + NativeMessagingHostPlatform::Windows, + &exact_limit, + "stdio", + &[allowed_origin.as_str()], + )?; + assert_eq!(accepted.executable_path().len(), exact_limit.len()); + + assert_eq!( + NativeMessagingHostManifest::parse( + host, + NativeMessagingHostPlatform::Windows, + &one_over, + "stdio", + &[allowed_origin.as_str()], + ), + Err(NativeMessagingHostManifestError::ExecutablePathTooLong) + ); + Ok(()) +} + +#[test] +fn manifest_rejects_non_stdio_empty_and_oversized_allowlists() { + let host = host_name("com.contextualwisdom.originweave"); + let allowed_origin = extension_origin(ALLOWED_EXTENSION); + + assert_eq!( + NativeMessagingHostManifest::parse( + host.clone(), + NativeMessagingHostPlatform::Linux, + LINUX_HOST_PATH, + "pipe", + &[allowed_origin.as_str()], + ), + Err(NativeMessagingHostManifestError::UnsupportedInterfaceType) + ); + assert_eq!( + NativeMessagingHostManifest::parse( + host.clone(), + NativeMessagingHostPlatform::Linux, + LINUX_HOST_PATH, + "stdio", + &[], + ), + Err(NativeMessagingHostManifestError::MissingAllowedOrigin) + ); + + let oversized = vec![allowed_origin.as_str(); MAX_NATIVE_MESSAGING_ALLOWED_ORIGINS + 1]; + assert_eq!( + NativeMessagingHostManifest::parse( + host, + NativeMessagingHostPlatform::Linux, + LINUX_HOST_PATH, + "stdio", + &oversized, + ), + Err(NativeMessagingHostManifestError::TooManyAllowedOrigins) + ); +} + +#[test] +fn manifest_rejects_ambiguous_or_wildcard_extension_origins() { + let host = host_name("com.contextualwisdom.originweave"); + let invalid_origins = [ + "chrome-extension://*/", + "https://abcdefghijklmnopabcdefghijklmnop/", + "chrome-extension://abcdefghijklmnopabcdefghijklmnop", + "chrome-extension://abcdefghijklmnopabcdefghijklmnop/path", + "chrome-extension://ABCDEFGHIJKLMNOPABCDEFGHIJKLMNOP/", + "chrome-extension://abcdefghijklmnopabcdefghijklmnop/?query=1", + ]; + + for invalid in invalid_origins { + assert_eq!( + NativeMessagingHostManifest::parse( + host.clone(), + NativeMessagingHostPlatform::Linux, + LINUX_HOST_PATH, + "stdio", + &[invalid], + ), + Err(NativeMessagingHostManifestError::InvalidAllowedOrigin), + "unexpected allowed origin: {invalid:?}" + ); + } +} + +#[test] +fn manifest_error_messages_are_deterministic_and_source_free() { + let cases = [ + ( + NativeMessagingHostManifestError::UnsupportedInterfaceType, + "native messaging host manifest interface type must be stdio", + ), + ( + NativeMessagingHostManifestError::InvalidExecutablePath, + "native messaging host manifest contains an invalid executable path", + ), + ( + NativeMessagingHostManifestError::ExecutablePathTooLong, + "native messaging host manifest executable path exceeds the OriginWeave safety budget", + ), + ( + NativeMessagingHostManifestError::RelativeExecutablePathUnsupported, + "native messaging host executable path must be absolute on this platform", + ), + ( + NativeMessagingHostManifestError::MissingAllowedOrigin, + "native messaging host manifest must allow at least one exact extension origin", + ), + ( + NativeMessagingHostManifestError::TooManyAllowedOrigins, + "native messaging host manifest exceeds the OriginWeave allowed-origin safety budget", + ), + ( + NativeMessagingHostManifestError::InvalidAllowedOrigin, + "native messaging host manifest contains an invalid extension origin", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-extension/tests/native_messaging_manifest_description.rs b/crates/originweave-extension/tests/native_messaging_manifest_description.rs new file mode 100644 index 000000000..ee525cdb3 --- /dev/null +++ b/crates/originweave-extension/tests/native_messaging_manifest_description.rs @@ -0,0 +1,31 @@ +use std::error::Error; + +use originweave_extension::{ + NativeMessagingHostPlatform, NativeMessagingManifestDocument, NativeMessagingManifestParseError, +}; + +#[test] +fn empty_native_messaging_host_description_is_not_chrome_valid() { + let result = NativeMessagingManifestDocument::parse( + br#"{ + "name":"com.contextualwisdom.originweave", + "description":"", + "path":"/opt/originweave/native-host", + "type":"stdio", + "allowed_origins":["chrome-extension://abcdefghijklmnopabcdefghijklmnop/"] + }"#, + ) + .map(|document| document.parse_host_manifest(NativeMessagingHostPlatform::Linux)); + + assert!(matches!( + result, + Ok(Err(NativeMessagingManifestParseError::InvalidFieldValue)) + )); + + let error = NativeMessagingManifestParseError::InvalidFieldValue; + assert_eq!( + error.to_string(), + "native messaging host manifest field has an invalid value" + ); + assert!(error.source().is_none()); +} diff --git a/crates/originweave-extension/tests/native_messaging_manifest_document.rs b/crates/originweave-extension/tests/native_messaging_manifest_document.rs new file mode 100644 index 000000000..00a0cb755 --- /dev/null +++ b/crates/originweave-extension/tests/native_messaging_manifest_document.rs @@ -0,0 +1,240 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_extension::{ + MAX_NATIVE_MESSAGING_MANIFEST_DOCUMENT_BYTES, NativeMessagingHostManifestError, + NativeMessagingHostPlatform, NativeMessagingManifestDocument, + NativeMessagingManifestDocumentError, NativeMessagingManifestParseError, +}; + +const ALLOWED_EXTENSION: &str = "abcdefghijklmnopabcdefghijklmnop"; +const LINUX_HOST_PATH: &str = "/opt/originweave/native-host"; + +#[test] +fn native_messaging_manifest_document_is_bounded_before_text_storage() { + let mut exact_limit = vec![b' '; MAX_NATIVE_MESSAGING_MANIFEST_DOCUMENT_BYTES]; + exact_limit[0] = b'{'; + exact_limit[MAX_NATIVE_MESSAGING_MANIFEST_DOCUMENT_BYTES - 1] = b'}'; + let document = NativeMessagingManifestDocument::parse(&exact_limit) + .expect("the exact OriginWeave manifest-document safety bound remains accepted"); + assert_eq!(document.as_str().len(), exact_limit.len()); + + let one_over = vec![b' '; MAX_NATIVE_MESSAGING_MANIFEST_DOCUMENT_BYTES + 1]; + assert_eq!( + NativeMessagingManifestDocument::parse(&one_over), + Err(NativeMessagingManifestDocumentError::DocumentTooLarge) + ); +} + +#[test] +fn native_messaging_manifest_document_rejects_empty_and_invalid_utf8() { + assert_eq!( + NativeMessagingManifestDocument::parse(&[]), + Err(NativeMessagingManifestDocumentError::EmptyDocument) + ); + assert_eq!( + NativeMessagingManifestDocument::parse(&[0xff]), + Err(NativeMessagingManifestDocumentError::InvalidUtf8) + ); +} + +#[test] +fn native_messaging_manifest_document_requires_outer_object_boundary() { + assert_eq!( + NativeMessagingManifestDocument::parse(b"[]"), + Err(NativeMessagingManifestDocumentError::InvalidObjectBoundary) + ); + assert_eq!( + NativeMessagingManifestDocument::parse(b"{"), + Err(NativeMessagingManifestDocumentError::InvalidObjectBoundary) + ); + + let document = NativeMessagingManifestDocument::parse(b" \r\n{\n}\t ") + .expect("JSON whitespace around an object-shaped document remains accepted"); + assert_eq!(document.as_str(), " \r\n{\n}\t "); +} + +#[test] +fn native_messaging_manifest_document_parses_complete_authority_fields() +-> Result<(), Box> { + let json = format!( + r#"{{ + "name": "com.contextualwisdom.originweave", + "description": "OriginWeave native host", + "path": "{LINUX_HOST_PATH}", + "type": "stdio", + "allowed_origins": ["chrome-extension://{ALLOWED_EXTENSION}/"], + "supports_native_initiated_connections": true + }}"# + ); + let document = NativeMessagingManifestDocument::parse(json.as_bytes())?; + let manifest = document.parse_host_manifest(NativeMessagingHostPlatform::Linux)?; + + assert_eq!( + manifest.host_name().as_str(), + "com.contextualwisdom.originweave" + ); + assert_eq!(manifest.platform(), NativeMessagingHostPlatform::Linux); + assert_eq!(manifest.executable_path(), LINUX_HOST_PATH); + assert_eq!(manifest.allowed_extension_count(), 1); + assert!(manifest.supports_native_initiated_connections()); + Ok(()) +} + +#[test] +fn native_messaging_manifest_document_decodes_json_string_escapes_before_validation() +-> Result<(), Box> { + let json = format!( + r#"{{ + "name": "com.contextualwisdom.origin\u0077eave", + "description": "Origin\\Weave \"native\" host", + "path": "\/opt\/originweave\/native-host", + "type": "st\u0064io", + "allowed_origins": ["chrome-extension:\/\/{ALLOWED_EXTENSION}\/"], + "supports_native_initiated_connections": false + }}"# + ); + let document = NativeMessagingManifestDocument::parse(json.as_bytes())?; + let manifest = document.parse_host_manifest(NativeMessagingHostPlatform::Linux)?; + + assert_eq!( + manifest.host_name().as_str(), + "com.contextualwisdom.originweave" + ); + assert_eq!(manifest.executable_path(), LINUX_HOST_PATH); + assert!(!manifest.supports_native_initiated_connections()); + Ok(()) +} + +#[test] +fn native_messaging_manifest_document_rejects_incomplete_or_ambiguous_json() { + for malformed in [ + r#"{"name":"com.contextualwisdom.originweave",}"#, + r#"{"name":"com.contextualwisdom.originweave" "description":"missing comma"}"#, + r#"{"name":"com.contextualwisdom.originweave","description":"bad\q"}"#, + r#"{"name":"com.contextualwisdom.originweave","description":"bad\uD800"}"#, + ] { + let document = NativeMessagingManifestDocument::parse(malformed.as_bytes()) + .expect("the pre-parser only proves the outer object boundary"); + assert_eq!( + document.parse_host_manifest(NativeMessagingHostPlatform::Linux), + Err(NativeMessagingManifestParseError::InvalidJson), + "unexpected malformed document: {malformed}" + ); + } +} + +#[test] +fn native_messaging_manifest_document_rejects_duplicate_unknown_missing_and_wrong_typed_fields() { + let duplicate = NativeMessagingManifestDocument::parse( + br#"{ + "name":"com.contextualwisdom.originweave", + "name":"com.contextualwisdom.other", + "description":"host", + "path":"/opt/originweave/native-host", + "type":"stdio", + "allowed_origins":["chrome-extension://abcdefghijklmnopabcdefghijklmnop/"] + }"#, + ) + .expect("outer object boundary remains valid"); + assert_eq!( + duplicate.parse_host_manifest(NativeMessagingHostPlatform::Linux), + Err(NativeMessagingManifestParseError::DuplicateField) + ); + + let unknown = NativeMessagingManifestDocument::parse( + br#"{ + "name":"com.contextualwisdom.originweave", + "description":"host", + "path":"/opt/originweave/native-host", + "type":"stdio", + "allowed_origins":["chrome-extension://abcdefghijklmnopabcdefghijklmnop/"], + "ambient_authority":true + }"#, + ) + .expect("outer object boundary remains valid"); + assert_eq!( + unknown.parse_host_manifest(NativeMessagingHostPlatform::Linux), + Err(NativeMessagingManifestParseError::UnknownField) + ); + + let missing_description = NativeMessagingManifestDocument::parse( + br#"{ + "name":"com.contextualwisdom.originweave", + "path":"/opt/originweave/native-host", + "type":"stdio", + "allowed_origins":["chrome-extension://abcdefghijklmnopabcdefghijklmnop/"] + }"#, + ) + .expect("outer object boundary remains valid"); + assert_eq!( + missing_description.parse_host_manifest(NativeMessagingHostPlatform::Linux), + Err(NativeMessagingManifestParseError::MissingRequiredField) + ); + + let wrong_type = NativeMessagingManifestDocument::parse( + br#"{ + "name":"com.contextualwisdom.originweave", + "description":"host", + "path":"/opt/originweave/native-host", + "type":"stdio", + "allowed_origins":"chrome-extension://abcdefghijklmnopabcdefghijklmnop/" + }"#, + ) + .expect("outer object boundary remains valid"); + assert_eq!( + wrong_type.parse_host_manifest(NativeMessagingHostPlatform::Linux), + Err(NativeMessagingManifestParseError::InvalidFieldType) + ); +} + +#[test] +fn native_messaging_manifest_document_preserves_typed_manifest_validation_failure() { + let document = NativeMessagingManifestDocument::parse( + br#"{ + "name":"com.contextualwisdom.originweave", + "description":"host", + "path":"/opt/originweave/native-host", + "type":"pipe", + "allowed_origins":["chrome-extension://abcdefghijklmnopabcdefghijklmnop/"] + }"#, + ) + .expect("outer object boundary remains valid"); + + let error = document + .parse_host_manifest(NativeMessagingHostPlatform::Linux) + .expect_err("unsupported Chrome interface type must fail closed"); + assert_eq!( + error, + NativeMessagingManifestParseError::Manifest( + NativeMessagingHostManifestError::UnsupportedInterfaceType + ) + ); + assert!(error.source().is_some()); +} + +#[test] +fn native_messaging_manifest_document_errors_are_standard_and_source_free() { + for (error, expected) in [ + ( + NativeMessagingManifestDocumentError::EmptyDocument, + "native messaging host manifest document is empty", + ), + ( + NativeMessagingManifestDocumentError::DocumentTooLarge, + "native messaging host manifest document exceeds the OriginWeave safety budget", + ), + ( + NativeMessagingManifestDocumentError::InvalidUtf8, + "native messaging host manifest document is not valid UTF-8", + ), + ( + NativeMessagingManifestDocumentError::InvalidObjectBoundary, + "native messaging host manifest document must have one outer JSON object boundary", + ), + ] { + assert_eq!(error.to_string(), expected); + assert!(error.source().is_none()); + } +} diff --git a/crates/originweave-extension/tests/native_messaging_manifest_document_edges.rs b/crates/originweave-extension/tests/native_messaging_manifest_document_edges.rs new file mode 100644 index 000000000..773f11337 --- /dev/null +++ b/crates/originweave-extension/tests/native_messaging_manifest_document_edges.rs @@ -0,0 +1,192 @@ +#![allow(clippy::expect_used)] + +use std::error::Error; + +use originweave_extension::{ + NativeMessagingHostPlatform, NativeMessagingManifestDocument, NativeMessagingManifestParseError, +}; + +const EXTENSION_ORIGIN: &str = "chrome-extension://abcdefghijklmnopabcdefghijklmnop/"; +const SECOND_EXTENSION_ORIGIN: &str = "chrome-extension://ponmlkjihgfedcbaponmlkjihgfedcba/"; + +fn parse_error(raw: &str) -> NativeMessagingManifestParseError { + NativeMessagingManifestDocument::parse(raw.as_bytes()) + .expect("edge fixture must pass only the bounded outer-object pre-parser") + .parse_host_manifest(NativeMessagingHostPlatform::Linux) + .expect_err("edge fixture must fail complete manifest parsing or authority validation") +} + +#[test] +fn complete_parser_rejects_every_duplicate_reviewed_field() { + let cases = [ + format!( + r#"{{"name":"com.contextualwisdom.originweave","name":"com.contextualwisdom.other","description":"host","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":["{EXTENSION_ORIGIN}"]}}"# + ), + format!( + r#"{{"name":"com.contextualwisdom.originweave","description":"host","description":"other","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":["{EXTENSION_ORIGIN}"]}}"# + ), + format!( + r#"{{"name":"com.contextualwisdom.originweave","description":"host","path":"/opt/originweave/native-host","path":"/tmp/other","type":"stdio","allowed_origins":["{EXTENSION_ORIGIN}"]}}"# + ), + format!( + r#"{{"name":"com.contextualwisdom.originweave","description":"host","path":"/opt/originweave/native-host","type":"stdio","type":"stdio","allowed_origins":["{EXTENSION_ORIGIN}"]}}"# + ), + format!( + r#"{{"name":"com.contextualwisdom.originweave","description":"host","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":["{EXTENSION_ORIGIN}"],"allowed_origins":["{EXTENSION_ORIGIN}"]}}"# + ), + format!( + r#"{{"name":"com.contextualwisdom.originweave","description":"host","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":["{EXTENSION_ORIGIN}"],"supports_native_initiated_connections":true,"supports_native_initiated_connections":false}}"# + ), + ]; + + for raw in cases { + assert_eq!( + parse_error(&raw), + NativeMessagingManifestParseError::DuplicateField + ); + } +} + +#[test] +fn complete_parser_covers_empty_and_malformed_array_and_boolean_shapes() { + let empty_object = NativeMessagingManifestDocument::parse(b"{}") + .expect("empty object passes only the bounded outer-object pre-parser"); + assert_eq!( + empty_object.parse_host_manifest(NativeMessagingHostPlatform::Linux), + Err(NativeMessagingManifestParseError::MissingRequiredField) + ); + + let empty_origins = r#"{"name":"com.contextualwisdom.originweave","description":"host","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":[]}"#.to_owned(); + assert!(matches!( + parse_error(&empty_origins), + NativeMessagingManifestParseError::Manifest(_) + )); + + let multiple_origins = format!( + r#"{{"name":"com.contextualwisdom.originweave","description":"host","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":["{EXTENSION_ORIGIN}","{SECOND_EXTENSION_ORIGIN}"]}}"# + ); + let multiple_manifest = NativeMessagingManifestDocument::parse(multiple_origins.as_bytes()) + .expect("valid multi-origin fixture passes pre-parser") + .parse_host_manifest(NativeMessagingHostPlatform::Linux) + .expect("valid multi-origin fixture passes complete parsing"); + assert_eq!(multiple_manifest.allowed_extension_count(), 2); + + for raw in [ + r#"{"name":"com.contextualwisdom.originweave","description":"host","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":[true]}"#.to_owned(), + format!(r#"{{"name":"com.contextualwisdom.originweave","description":"host","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":"{EXTENSION_ORIGIN}"}}"#), + ] { + assert_eq!(parse_error(&raw), NativeMessagingManifestParseError::InvalidFieldType); + } + + for raw in [ + format!( + r#"{{"name":"com.contextualwisdom.originweave","description":"host","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":["{EXTENSION_ORIGIN}",]}}"# + ), + format!( + r#"{{"name":"com.contextualwisdom.originweave","description":"host","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":["{EXTENSION_ORIGIN}" "{EXTENSION_ORIGIN}"]}}"# + ), + "{} {}".to_owned(), + ] { + assert_eq!( + parse_error(&raw), + NativeMessagingManifestParseError::InvalidJson + ); + } + + let invalid_boolean = format!( + r#"{{"name":"com.contextualwisdom.originweave","description":"host","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":["{EXTENSION_ORIGIN}"],"supports_native_initiated_connections":1}}"# + ); + assert_eq!( + parse_error(&invalid_boolean), + NativeMessagingManifestParseError::InvalidFieldType + ); + + let false_boolean = format!( + r#"{{"name":"com.contextualwisdom.originweave","description":"host","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":["{EXTENSION_ORIGIN}"],"supports_native_initiated_connections":false}}"# + ); + let manifest = NativeMessagingManifestDocument::parse(false_boolean.as_bytes()) + .expect("valid false-boolean fixture passes pre-parser") + .parse_host_manifest(NativeMessagingHostPlatform::Linux) + .expect("valid false-boolean fixture passes complete parsing"); + assert!(!manifest.supports_native_initiated_connections()); +} + +#[test] +fn complete_parser_covers_json_escape_and_unicode_failure_edges() { + let escaped_description = format!( + r#"{{"name":"com.contextualwisdom.originweave","description":"a\b\f\n\r\t-\u0041-\u00E9-\u263A-\uD83D\uDE00-\u00AF","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":["{EXTENSION_ORIGIN}"]}}"# + ); + let manifest = NativeMessagingManifestDocument::parse(escaped_description.as_bytes()) + .expect("valid escaped-string fixture passes pre-parser") + .parse_host_manifest(NativeMessagingHostPlatform::Linux) + .expect("valid escaped-string fixture passes complete parsing"); + assert_eq!(manifest.allowed_extension_count(), 1); + + for description in [ + r#"bad\q"#, + r#"bad\uD83D"#, + r#"bad\uD83D\x0000"#, + r#"bad\uD83D\u0041"#, + r#"bad\uDE00"#, + r#"bad\u12"#, + r#"bad\u00G0"#, + ] { + let raw = format!( + r#"{{"name":"com.contextualwisdom.originweave","description":"{description}","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":["{EXTENSION_ORIGIN}"]}}"# + ); + assert_eq!( + parse_error(&raw), + NativeMessagingManifestParseError::InvalidJson + ); + } + + let raw_control = format!( + "{{\"name\":\"com.contextualwisdom.originweave\",\"description\":\"bad\u{0001}text\",\"path\":\"/opt/originweave/native-host\",\"type\":\"stdio\",\"allowed_origins\":[\"{EXTENSION_ORIGIN}\"]}}" + ); + assert_eq!( + parse_error(&raw_control), + NativeMessagingManifestParseError::InvalidJson + ); + + let unterminated = r#"{"name":"unterminated}"#; + assert_eq!( + parse_error(unterminated), + NativeMessagingManifestParseError::InvalidJson + ); +} + +#[test] +fn parse_errors_expose_deterministic_display_and_only_causal_sources() { + for error in [ + NativeMessagingManifestParseError::InvalidJson, + NativeMessagingManifestParseError::DuplicateField, + NativeMessagingManifestParseError::UnknownField, + NativeMessagingManifestParseError::MissingRequiredField, + NativeMessagingManifestParseError::InvalidFieldType, + ] { + assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); + } + + let invalid_host = format!( + r#"{{"name":"INVALID HOST","description":"host","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":["{EXTENSION_ORIGIN}"]}}"# + ); + let host_error = parse_error(&invalid_host); + assert!(matches!( + host_error, + NativeMessagingManifestParseError::HostName(_) + )); + assert!(!host_error.to_string().is_empty()); + assert!(host_error.source().is_some()); + + let invalid_manifest = format!( + r#"{{"name":"com.contextualwisdom.originweave","description":"host","path":"/opt/originweave/native-host","type":"pipe","allowed_origins":["{EXTENSION_ORIGIN}"]}}"# + ); + let manifest_error = parse_error(&invalid_manifest); + assert!(matches!( + manifest_error, + NativeMessagingManifestParseError::Manifest(_) + )); + assert!(!manifest_error.to_string().is_empty()); + assert!(manifest_error.source().is_some()); +} diff --git a/crates/originweave-extension/tests/native_messaging_manifest_origin_budget.rs b/crates/originweave-extension/tests/native_messaging_manifest_origin_budget.rs new file mode 100644 index 000000000..66d4f251b --- /dev/null +++ b/crates/originweave-extension/tests/native_messaging_manifest_origin_budget.rs @@ -0,0 +1,25 @@ +#![allow(clippy::expect_used)] + +use originweave_extension::{ + NativeMessagingHostPlatform, NativeMessagingManifestDocument, NativeMessagingManifestParseError, +}; + +const EXTENSION_ORIGIN: &str = "chrome-extension://abcdefghijklmnopabcdefghijklmnop/"; + +#[test] +fn complete_parser_enforces_origin_budget_before_decoding_excess_element() { + let allowed_origins = vec![format!("\"{EXTENSION_ORIGIN}\""); 256].join(","); + let raw = format!( + r#"{{"name":"com.contextualwisdom.originweave","description":"host","path":"/opt/originweave/native-host","type":"stdio","allowed_origins":[{allowed_origins},1]}}"# + ); + + let error = NativeMessagingManifestDocument::parse(raw.as_bytes()) + .expect("bounded over-budget fixture must pass the document pre-parser") + .parse_host_manifest(NativeMessagingHostPlatform::Linux) + .expect_err("the 257th origin entry must fail at the origin-count budget before decoding"); + + assert!(matches!( + error, + NativeMessagingManifestParseError::Manifest(_) + )); +} diff --git a/crates/originweave-extension/tests/native_messaging_manifest_syntax_boundary.rs b/crates/originweave-extension/tests/native_messaging_manifest_syntax_boundary.rs new file mode 100644 index 000000000..d0e6b08c9 --- /dev/null +++ b/crates/originweave-extension/tests/native_messaging_manifest_syntax_boundary.rs @@ -0,0 +1,60 @@ +#![allow(clippy::expect_used)] + +use originweave_extension::{ + NativeMessagingHostPlatform, NativeMessagingManifestDocument, NativeMessagingManifestParseError, +}; + +fn parse_error(bytes: &[u8]) -> NativeMessagingManifestParseError { + NativeMessagingManifestDocument::parse(bytes) + .expect("malformed object fixture must pass only the bounded outer-object pre-parser") + .parse_host_manifest(NativeMessagingHostPlatform::Linux) + .expect_err("malformed manifest fixture must fail complete parsing") +} + +#[test] +fn complete_parser_rejects_a_missing_member_separator_after_bounded_admission() { + assert_eq!( + parse_error(br#"{"name" "value"}"#), + NativeMessagingManifestParseError::InvalidJson + ); +} + +#[test] +fn complete_parser_rejects_a_non_string_member_key_after_bounded_admission() { + assert_eq!( + parse_error(br#"{?}"#), + NativeMessagingManifestParseError::InvalidJson + ); +} + +#[test] +fn complete_parser_rejects_non_string_required_fields_through_the_public_boundary() { + assert_eq!( + parse_error(br#"{"name":true}"#), + NativeMessagingManifestParseError::InvalidFieldType + ); +} + +#[test] +fn complete_parser_rejects_a_unicode_escape_truncated_by_the_outer_object_boundary() { + assert_eq!( + parse_error(br#"{"name":"\u1"}"#), + NativeMessagingManifestParseError::InvalidJson + ); +} + +#[test] +fn complete_parser_preserves_nested_string_failures_at_each_schema_boundary() { + for raw in [ + br#"{"\q":"value"}"#.as_slice(), + br#"{"path":"\q"}"#.as_slice(), + br#"{"type":"\q"}"#.as_slice(), + br#"{"allowed_origins":["\q"]}"#.as_slice(), + br#"{"name":"\uD83D\u12"}"#.as_slice(), + ] { + assert_eq!( + parse_error(raw), + NativeMessagingManifestParseError::InvalidJson + ); + } +} diff --git a/tests/test_native_messaging_manifest_context_architecture.py b/tests/test_native_messaging_manifest_context_architecture.py new file mode 100644 index 000000000..3e256d918 --- /dev/null +++ b/tests/test_native_messaging_manifest_context_architecture.py @@ -0,0 +1,32 @@ +"""Architectural fitness for native-messaging manifest ownership.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +class NativeMessagingManifestContextArchitectureTest(unittest.TestCase): + """Keep Chrome native-host manifest behavior inside the Extension Policy context.""" + + def test_manifest_behavior_belongs_to_extension_context(self) -> None: + """Host-manifest parsing must not leak back into stable core contracts.""" + workspace = (ROOT / "Cargo.toml").read_text(encoding="utf-8") + self.assertIn('"crates/originweave-extension"', workspace) + + extension_source = ROOT / "crates" / "originweave-extension" / "src" + self.assertTrue((extension_source / "native_messaging_manifest.rs").is_file()) + self.assertTrue((extension_source / "native_messaging_manifest_document.rs").is_file()) + + core_source = ROOT / "crates" / "originweave-core" / "src" + self.assertFalse((core_source / "native_messaging_manifest.rs").exists()) + self.assertFalse((core_source / "native_messaging_manifest_document.rs").exists()) + core_entry = (core_source / "root.rs").read_text(encoding="utf-8") + self.assertNotIn("native_messaging_manifest", core_entry) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py index 057a0011b..0db84ef69 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-extension", "crates/originweave-bap", "crates/originweave-policy", "crates/originweave-destination",