From 17311661ed09986105e96b309b3ebb16c63a3333 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 11:36:32 +0000 Subject: [PATCH 1/9] feat(egress): expose local provider endpoint classification Add a local_provider module and re-export classify_provider_endpoint so callers can determine whether an endpoint belongs to a locally hosted provider. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-egress/src/lib.rs | 2 ++ .../tinysecurity-egress/src/local_provider.rs | 5 +++ .../src/local_provider_tests.rs | 36 +++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 crates/tinysecurity-egress/src/local_provider.rs create mode 100644 crates/tinysecurity-egress/src/local_provider_tests.rs diff --git a/crates/tinysecurity-egress/src/lib.rs b/crates/tinysecurity-egress/src/lib.rs index 33c7357b..7cc8b4a5 100644 --- a/crates/tinysecurity-egress/src/lib.rs +++ b/crates/tinysecurity-egress/src/lib.rs @@ -2,6 +2,8 @@ mod privacy; mod privacy_check; pub use privacy_check::privacy_verdict; +mod local_provider; +pub use local_provider::classify_provider_endpoint; mod registry; mod resolver; mod url_guard; diff --git a/crates/tinysecurity-egress/src/local_provider.rs b/crates/tinysecurity-egress/src/local_provider.rs new file mode 100644 index 00000000..a02104c9 --- /dev/null +++ b/crates/tinysecurity-egress/src/local_provider.rs @@ -0,0 +1,5 @@ +//! Same-machine local provider endpoint classification for LocalOnly privacy mode. + +#[cfg(test)] +#[path = "local_provider_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-egress/src/local_provider_tests.rs b/crates/tinysecurity-egress/src/local_provider_tests.rs new file mode 100644 index 00000000..01f765a5 --- /dev/null +++ b/crates/tinysecurity-egress/src/local_provider_tests.rs @@ -0,0 +1,36 @@ +use super::*; + +use tinysecurity_bus::ProviderEndpointLocality2::{External, OnDevice}; + +#[test] +fn classifies_only_same_machine_reserved_provider_hosts_as_on_device() { + for host in [ + "localhost", + "worker.localhost", + "127.0.0.1", + "127.25.3.4", + "0.0.0.0", + "[::1]", + "[::]", + "[::ffff:127.0.0.1]", + ] { + assert_eq!(classify_provider_endpoint(host), OnDevice, "{host}"); + } +} + +#[test] +fn treats_lan_and_non_loopback_reserved_addresses_as_external() { + for host in [ + "192.168.1.5", + "10.0.0.8", + "172.16.0.2", + "169.254.1.2", + "[fe80::1]", + "[fd00::1]", + "example.local", + "localhost.example", + "not a host", + ] { + assert_eq!(classify_provider_endpoint(host), External, "{host}"); + } +} From 8e7c60f6987ffda1c73babde01c7322fa9e6939d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 11:37:44 +0000 Subject: [PATCH 2/9] feat(bus): add provider endpoint locality classification Add a ClassifyProviderEndpoint2 bus method and contract version 1.32 so LocalOnly privacy mode can decide whether a configured provider host is on-device. Only loopback and unspecified IP literals plus localhost names count as on-device; LAN addresses stay external and unrecognized hosts fail closed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/lib.rs | 4 +- crates/tinysecurity-bus/src/names.rs | 3 ++ .../tinysecurity-bus/src/provider_endpoint.rs | 53 +++++++++++++++++++ .../src/provider_endpoint_tests.rs | 40 ++++++++++++++ .../tinysecurity-egress/src/local_provider.rs | 44 +++++++++++++++ crates/tinysecurity-module/src/adapter.rs | 13 ++++- 6 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 crates/tinysecurity-bus/src/provider_endpoint.rs create mode 100644 crates/tinysecurity-bus/src/provider_endpoint_tests.rs diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 83c8d02d..22af6ee6 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -38,6 +38,7 @@ mod path_batch; mod path_policy; mod policy; mod privacy_check; +mod provider_endpoint; mod prompt; mod prompt_score; mod redact; @@ -57,6 +58,7 @@ pub use path_batch::*; pub use path_policy::*; pub use policy::*; pub use privacy_check::*; +pub use provider_endpoint::*; pub use prompt::*; pub use prompt_score::*; pub use redact::RedactWithRequest; @@ -65,7 +67,7 @@ pub use scoped_policy::*; pub use tool_trust::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (1, 31); +pub const CONTRACT_VERSION: (u32, u32) = (1, 32); /// Whether a remote module's advertised version supplies this host's vocabulary. /// /// `version` is the provider version received by the host, not a host requirement diff --git a/crates/tinysecurity-bus/src/names.rs b/crates/tinysecurity-bus/src/names.rs index d12e6bd7..b1c815ef 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -105,6 +105,8 @@ pub mod methods { pub const CLASSIFY_HOST_ACTION2: &str = "ClassifyHostAction2"; /// Descriptor-level privacy decision for a transfer with no pinned destination (contract 1.22). pub const PRIVACY_CHECK2: &str = "PrivacyCheck2"; + /// Classify a configured provider host as same-machine or external (contract 1.32). + pub const CLASSIFY_PROVIDER_ENDPOINT2: &str = "ClassifyProviderEndpoint2"; /// Register an immutable per-subject policy over path and command scopes. pub const REGISTER_POLICY: &str = "RegisterPolicy"; /// Atomically replace an active policy, with human review when it widens. @@ -221,6 +223,7 @@ pub const METHODS: &[&str] = &[ methods::PEEK_BUDGET2, methods::CLASSIFY_HOST_ACTION2, methods::PRIVACY_CHECK2, + methods::CLASSIFY_PROVIDER_ENDPOINT2, methods::REGISTER_COMMAND_POLICY, methods::CLASSIFY_COMMAND, methods::CHECK_COMMAND, diff --git a/crates/tinysecurity-bus/src/provider_endpoint.rs b/crates/tinysecurity-bus/src/provider_endpoint.rs new file mode 100644 index 00000000..8f834ee5 --- /dev/null +++ b/crates/tinysecurity-bus/src/provider_endpoint.rs @@ -0,0 +1,53 @@ +//! Host endpoint locality classification for LocalOnly privacy mode (contract 1.32). +use serde::{Deserialize, Serialize}; + +/// Maximum size of a parsed URL host supplied for provider locality classification. +pub const MAX_PROVIDER_HOST_BYTES: usize = 255; + +/// The parsed host component of a configured provider endpoint. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProviderEndpointRequest2 { + /// Host only, as returned by the URL parser; excludes scheme, port, path, query and fragment. + pub host: String, +} + +impl ProviderEndpointRequest2 { + /// Validate the bounded host input before classifying it. + /// + /// # Errors + /// Returns `InvalidArguments` for an empty, oversized, whitespace-containing or + /// control-character-containing host. + pub fn validate(&self) -> crate::Result<()> { + if self.host.is_empty() + || self.host.len() > MAX_PROVIDER_HOST_BYTES + || self.host.chars().any(|c| c.is_control() || c.is_whitespace()) + { + return Err(crate::Error::InvalidArguments); + } + Ok(()) + } +} + +/// Whether a provider host is reserved for the same machine or may be elsewhere. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ProviderEndpointLocality2 { + /// A loopback or localhost name that identifies this machine itself. + OnDevice, + /// Any other host, including private or link-local network addresses. + External, +} + +/// Result of classifying one configured provider host. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProviderEndpointResponse2 { + /// Locality used by the host's LocalOnly decision. + pub locality: ProviderEndpointLocality2, +} + +#[cfg(test)] +#[path = "provider_endpoint_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-bus/src/provider_endpoint_tests.rs b/crates/tinysecurity-bus/src/provider_endpoint_tests.rs new file mode 100644 index 00000000..85ec27b1 --- /dev/null +++ b/crates/tinysecurity-bus/src/provider_endpoint_tests.rs @@ -0,0 +1,40 @@ +use super::*; + +#[test] +fn provider_endpoint_request_is_bounded_and_host_only() { + assert!(ProviderEndpointRequest2 { + host: "[::1]".into() + } + .validate() + .is_ok()); + for host in ["", "has white space", "host\nname"] { + assert!(ProviderEndpointRequest2 { host: host.into() } + .validate() + .is_err()); + } + assert!(ProviderEndpointRequest2 { + host: "a".repeat(MAX_PROVIDER_HOST_BYTES + 1) + } + .validate() + .is_err()); +} + +#[test] +fn provider_endpoint_locality_has_stable_wire_names_and_method() { + assert_eq!( + serde_json::to_value(ProviderEndpointLocality2::OnDevice).unwrap(), + serde_json::json!("on_device") + ); + assert_eq!( + serde_json::to_value(ProviderEndpointLocality2::External).unwrap(), + serde_json::json!("external") + ); + assert_eq!( + crate::names::methods::CLASSIFY_PROVIDER_ENDPOINT2, + "ClassifyProviderEndpoint2" + ); + assert!(crate::names::METHODS.contains(&crate::names::methods::CLASSIFY_PROVIDER_ENDPOINT2)); + assert_eq!(CONTRACT_VERSION, (1, 32)); + assert!(crate::is_compatible((1, 32))); + assert!(!crate::is_compatible((1, 31))); +} diff --git a/crates/tinysecurity-egress/src/local_provider.rs b/crates/tinysecurity-egress/src/local_provider.rs index a02104c9..4657c516 100644 --- a/crates/tinysecurity-egress/src/local_provider.rs +++ b/crates/tinysecurity-egress/src/local_provider.rs @@ -1,4 +1,48 @@ //! Same-machine local provider endpoint classification for LocalOnly privacy mode. +use std::net::{Ipv4Addr, Ipv6Addr}; +use tinysecurity_bus::{ProviderEndpointLocality2, ProviderEndpointRequest2}; +use url::Host; + +/// Classify a host literal for a local provider endpoint without performing DNS. +/// +/// Only loopback and unspecified IP literals plus `localhost` names are considered +/// on-device. RFC 1918, link-local, IPv6 ULA and other LAN addresses remain external: +/// LocalOnly means that user data does not leave this machine, not merely this LAN. +/// Invalid or unrecognized hosts fail closed as external. +#[must_use] +pub fn classify_provider_endpoint(host: &str) -> ProviderEndpointLocality2 { + let Ok(host) = Host::parse(host) else { + return ProviderEndpointLocality2::External; + }; + let on_device = match host { + Host::Ipv4(address) => local_v4(address), + Host::Ipv6(address) => local_v6(address), + Host::Domain(name) => { + let name = name.trim_end_matches('.'); + name.eq_ignore_ascii_case("localhost") + || name + .rsplit_once('.') + .is_some_and(|(_, suffix)| suffix.eq_ignore_ascii_case("localhost")) + || name.to_ascii_lowercase().ends_with(".localhost") + } + }; + if on_device { + ProviderEndpointLocality2::OnDevice + } else { + ProviderEndpointLocality2::External + } +} + +fn local_v4(address: Ipv4Addr) -> bool { + address.is_loopback() || address.is_unspecified() +} + +fn local_v6(address: Ipv6Addr) -> bool { + address + .to_ipv4_mapped() + .map(local_v4) + .unwrap_or_else(|| address.is_loopback() || address.is_unspecified()) +} #[cfg(test)] #[path = "local_provider_tests.rs"] diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index b989130e..cd8d42da 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -621,6 +621,17 @@ impl PolicyService { .await } + async fn classify_provider_endpoint2( + &self, + request: tinysecurity_bus::ProviderEndpointRequest2, + ) -> BusResult { + request.validate().map_err(|error| failure(&error))?; + std::future::ready(Ok(tinysecurity_bus::ProviderEndpointResponse2 { + locality: tinysecurity::classify_provider_endpoint(&request.host), + })) + .await + } + async fn register_command_policy( &self, policy: tinysecurity_bus::CommandPolicy, @@ -2205,7 +2216,7 @@ tinybus_module::module_export! { config = ModuleConfig, worker_threads = 1, provides = ["ai.tinyhumans.tinysecurity.Policy"], - methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "DevicePairing2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "ListRecentDecisions2", "PurgeApprovalSession2", "RecordAuditError2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "GrantTool2", "RevokeTool2", "RevokeToolById2", "ListToolTrust2", "ImportTrustSource2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSecretScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "AuthorizeBrowserDial", "ConsumeBrowserDial", "ValidateBrowserDial", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], + methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "DevicePairing2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "ListRecentDecisions2", "PurgeApprovalSession2", "RecordAuditError2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "GrantTool2", "RevokeTool2", "RevokeToolById2", "ListToolTrust2", "ImportTrustSource2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSecretScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "ClassifyProviderEndpoint2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "AuthorizeBrowserDial", "ConsumeBrowserDial", "ValidateBrowserDial", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], signals = ["PolicyChanged"], requires = [], optional = ["ai.tinyhumans.tinysecurity.KeyringConsent2", "ai.tinyhumans.tinysecurity.SecretCipherConsumer2", "ai.tinyhumans.tinysecurity.SecretBackend2", "ai.tinyhumans.tinysecurity.DevicePairingHost2", "ai.tinyhumans.tinysecurity.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], From 910e591a011aedcdee480e0c085a8e22e264fd86 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 11:38:52 +0000 Subject: [PATCH 3/9] feat(bus): add provider endpoint classification and bump contract to 1.32 Adds a ClassifyProviderEndpoint2 method that validates a host-only provider endpoint request, rejecting empty, oversized, control-character, and whitespace-bearing hosts. The contract version moves to 1.32 so older 1.31 peers are no longer considered compatible, and the new member is advertised alongside the existing method constants. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/device_frame_tests.rs | 6 +-- .../src/device_pairing_tests.rs | 2 +- crates/tinysecurity-bus/src/lib.rs | 4 +- .../tinysecurity-bus/src/provider_endpoint.rs | 5 ++- .../src/provider_endpoint_tests.rs | 32 ++++++++------ crates/tinysecurity-bus/tests/scoped_wire.rs | 8 ++-- crates/tinysecurity-bus/tests/wire.rs | 2 +- .../src/adapter_gap_policy_tests.rs | 43 +++++++++++++++++++ docs/README.md | 2 + docs/egress.md | 10 ++++- docs/plans/local-provider-endpoint-1.32.md | 15 +++++++ docs/specs/local-provider-endpoint-1.32.md | 25 +++++++++++ 12 files changed, 129 insertions(+), 25 deletions(-) create mode 100644 docs/plans/local-provider-endpoint-1.32.md create mode 100644 docs/specs/local-provider-endpoint-1.32.md diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index d78ee5b9..576169ec 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -70,9 +70,9 @@ fn frame_operations_redact_both_payloads_without_changing_open_wire_shape() #[test] fn frame_member_name_is_stable_and_contract_version_is_bumped() { assert_eq!(crate::names::methods::DEVICE_FRAME2, "DeviceFrame2"); - assert_eq!(crate::CONTRACT_VERSION, (1, 31)); - assert!(crate::is_compatible((1, 31))); - assert!(crate::is_compatible((1, 31))); + assert_eq!(crate::CONTRACT_VERSION, (1, 32)); + assert!(crate::is_compatible((1, 32))); + assert!(!crate::is_compatible((1, 31))); assert!(!crate::is_compatible((1, 29))); assert!(!crate::is_compatible((2, 30))); } diff --git a/crates/tinysecurity-bus/src/device_pairing_tests.rs b/crates/tinysecurity-bus/src/device_pairing_tests.rs index b741397b..ffd90507 100644 --- a/crates/tinysecurity-bus/src/device_pairing_tests.rs +++ b/crates/tinysecurity-bus/src/device_pairing_tests.rs @@ -85,7 +85,7 @@ fn refresh_and_close_are_distinct_transient_session_operations() { #[test] fn pairing_contract_is_versioned_after_the_frame_contract() { - assert_eq!(CONTRACT_VERSION, (1, 31)); + assert_eq!(CONTRACT_VERSION, (1, 32)); } #[test] diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 22af6ee6..8d8121b4 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -38,9 +38,9 @@ mod path_batch; mod path_policy; mod policy; mod privacy_check; -mod provider_endpoint; mod prompt; mod prompt_score; +mod provider_endpoint; mod redact; mod scoped_policy; mod tool_trust; @@ -58,9 +58,9 @@ pub use path_batch::*; pub use path_policy::*; pub use policy::*; pub use privacy_check::*; -pub use provider_endpoint::*; pub use prompt::*; pub use prompt_score::*; +pub use provider_endpoint::*; pub use redact::RedactWithRequest; pub use redact::RedactorConfig; pub use scoped_policy::*; diff --git a/crates/tinysecurity-bus/src/provider_endpoint.rs b/crates/tinysecurity-bus/src/provider_endpoint.rs index 8f834ee5..25dd30a5 100644 --- a/crates/tinysecurity-bus/src/provider_endpoint.rs +++ b/crates/tinysecurity-bus/src/provider_endpoint.rs @@ -21,7 +21,10 @@ impl ProviderEndpointRequest2 { pub fn validate(&self) -> crate::Result<()> { if self.host.is_empty() || self.host.len() > MAX_PROVIDER_HOST_BYTES - || self.host.chars().any(|c| c.is_control() || c.is_whitespace()) + || self + .host + .chars() + .any(|c| c.is_control() || c.is_whitespace()) { return Err(crate::Error::InvalidArguments); } diff --git a/crates/tinysecurity-bus/src/provider_endpoint_tests.rs b/crates/tinysecurity-bus/src/provider_endpoint_tests.rs index 85ec27b1..3450908c 100644 --- a/crates/tinysecurity-bus/src/provider_endpoint_tests.rs +++ b/crates/tinysecurity-bus/src/provider_endpoint_tests.rs @@ -2,21 +2,27 @@ use super::*; #[test] fn provider_endpoint_request_is_bounded_and_host_only() { - assert!(ProviderEndpointRequest2 { - host: "[::1]".into() - } - .validate() - .is_ok()); + assert!( + ProviderEndpointRequest2 { + host: "[::1]".into() + } + .validate() + .is_ok() + ); for host in ["", "has white space", "host\nname"] { - assert!(ProviderEndpointRequest2 { host: host.into() } - .validate() - .is_err()); + assert!( + ProviderEndpointRequest2 { host: host.into() } + .validate() + .is_err() + ); } - assert!(ProviderEndpointRequest2 { - host: "a".repeat(MAX_PROVIDER_HOST_BYTES + 1) - } - .validate() - .is_err()); + assert!( + ProviderEndpointRequest2 { + host: "a".repeat(MAX_PROVIDER_HOST_BYTES + 1) + } + .validate() + .is_err() + ); } #[test] diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index e6514509..019a9d9d 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -83,6 +83,7 @@ fn new_members_are_advertised_and_named_by_contract_constants() { names::methods::LIST_RECENT_DECISIONS2, names::methods::PURGE_APPROVAL_SESSION2, names::methods::RECORD_AUDIT_ERROR2, + names::methods::CLASSIFY_PROVIDER_ENDPOINT2, ] { assert!(names::METHODS.contains(&name), "{name}"); } @@ -91,10 +92,11 @@ fn new_members_are_advertised_and_named_by_contract_constants() { #[test] fn contract_bump_is_additive_for_older_hosts_and_modules() { - assert_eq!(CONTRACT_VERSION, (1, 31)); - // A host built against 1.31 accepts this module and newer minors, not a 1.30 module. - assert!(is_compatible((1, 31))); + assert_eq!(CONTRACT_VERSION, (1, 32)); + // A host built against 1.32 accepts this module and newer minors, not a 1.31 module. assert!(is_compatible((1, 32))); + assert!(is_compatible((1, 33))); + assert!(!is_compatible((1, 31))); assert!(!is_compatible((1, 30))); assert!(!is_compatible((1, 29))); assert!(!is_compatible((1, 28))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index 70bb4821..765452f8 100644 --- a/crates/tinysecurity-bus/tests/wire.rs +++ b/crates/tinysecurity-bus/tests/wire.rs @@ -343,7 +343,7 @@ fn redactor_init_is_additive_and_secrets_never_enter_public_info() serde_json::to_value(&config)?["redactor"]["secrets"][0], "literal-private" ); - assert_eq!(CONTRACT_VERSION, (1, 31)); + assert_eq!(CONTRACT_VERSION, (1, 32)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), diff --git a/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs b/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs index 522a5172..ae24bf63 100644 --- a/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs +++ b/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs @@ -545,6 +545,49 @@ async fn privacy_check_decides_and_reports_the_live_generation() -> TestResult { Ok(()) } +#[tokio::test] +async fn provider_endpoint_classification_is_locality_only_not_lan_reachability() -> TestResult { + let service = service()?; + for (host, expected) in [ + ( + "localhost", + tinysecurity_bus::ProviderEndpointLocality2::OnDevice, + ), + ( + "[::ffff:127.0.0.1]", + tinysecurity_bus::ProviderEndpointLocality2::OnDevice, + ), + ( + "192.168.1.12", + tinysecurity_bus::ProviderEndpointLocality2::External, + ), + ( + "[fd00::12]", + tinysecurity_bus::ProviderEndpointLocality2::External, + ), + ] { + let response: tinysecurity_bus::ProviderEndpointResponse2 = call( + &service, + names::methods::CLASSIFY_PROVIDER_ENDPOINT2, + tinysecurity_bus::ProviderEndpointRequest2 { host: host.into() }, + ) + .await?; + assert_eq!(response.locality, expected, "{host}"); + } + assert!( + call::<_, tinysecurity_bus::ProviderEndpointResponse2>( + &service, + names::methods::CLASSIFY_PROVIDER_ENDPOINT2, + tinysecurity_bus::ProviderEndpointRequest2 { + host: "bad host".into(), + }, + ) + .await + .is_err() + ); + Ok(()) +} + #[tokio::test] async fn host_action_class_does_not_depend_on_audit_configuration() -> TestResult { // Without a required audit configured the members answer; the informational diff --git a/docs/README.md b/docs/README.md index 0c55fea7..878ecac4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,8 @@ docs/ - **[`module-crypto.md`](module-crypto.md)** — confidential device, master, cipher, password, keyring and pairing members of the TinySecurity module contract. - **[`module-subject-policies.md`](module-subject-policies.md)** — registered subject policy members and their generation-bound authorization rules. - **[`specs/device-pairing-contract-1.25.md`](specs/device-pairing-contract-1.25.md)** — host-attested paired-device lifecycle contract and trust boundaries. +- **[`specs/local-provider-endpoint-1.32.md`](specs/local-provider-endpoint-1.32.md)** — same-machine-only provider locality for `LocalOnly` privacy checks. +- **[`plans/local-provider-endpoint-1.32.md`](plans/local-provider-endpoint-1.32.md)** — ordered implementation and verification steps for provider locality classification. - **[`plans/device-pairing-contract-1.25.md`](plans/device-pairing-contract-1.25.md)** — ordered implementation and verification steps for pairing lifecycle support. - **[`specs/browser-dial-contract-1.28.md`](specs/browser-dial-contract-1.28.md)** — destination-only, one-use socket authorization for opaque browser traffic. - **[`specs/runtime-secret-scopes-contract-1.29.md`](specs/runtime-secret-scopes-contract-1.29.md)** — host-only runtime secret registration bound to live approval scopes. diff --git a/docs/egress.md b/docs/egress.md index 8e8416d7..9005ad86 100644 --- a/docs/egress.md +++ b/docs/egress.md @@ -12,7 +12,15 @@ A later authenticated TinyBus sender contract will enforce that channel boundary additionally supports an explicit host-local service capability. That capability is an exact loopback IP origin, path and independently constructed host metadata category; it never grants a generic agent URL access to private addresses. -`LocalOnly` blocks nonlocal requests before DNS. Standard requires a declared +`LocalOnly` blocks nonlocal requests before DNS. Hosts classify configured +provider endpoint hosts through `ClassifyProviderEndpoint2`: only loopback, +unspecified and `localhost` names are on-device. RFC 1918/LAN, link-local and +IPv6 unique-local hosts are external because they can name another device; no +DNS lookup is used to classify names. If classification fails, treat the +endpoint as external. See the +[contract 1.32 specification](specs/local-provider-endpoint-1.32.md). +`Sensitive` denies unassessed external user content; it allows only assessed +`none`/`low` user content with no identifying categories. Standard requires a declared endpoint and data category. Sensitive additionally refuses credentials, identifiers, unknown classification and original payloads, URLs or headers the shared redactor changes. The descriptor is the registered policy's diff --git a/docs/plans/local-provider-endpoint-1.32.md b/docs/plans/local-provider-endpoint-1.32.md new file mode 100644 index 00000000..eb126d9c --- /dev/null +++ b/docs/plans/local-provider-endpoint-1.32.md @@ -0,0 +1,15 @@ +# Local provider endpoint implementation plan + +1. Add the transport-free 1.32 request and response types and the advertised + `ClassifyProviderEndpoint2` method name. Pin bounds, wire names and contract + compatibility in bus tests. +2. Implement host-literal classification in `tinysecurity-egress`: parse with + the existing URL dependency, avoid DNS, classify same-machine-reserved + hosts only, and test loopback, mapped loopback, unspecified, LAN, ULA, + link-local, names and malformed input. +3. Dispatch the method from the native module adapter and exercise it through + the TinyBus service tests, including invalid bounded input. +4. Document how hosts feed the resulting locality into `PrivacyCheck2`, the + fail-closed behavior, and the existing `Sensitive` assessment rule. +5. Run the focused bus, egress and module tests, then workspace fmt, Clippy, + build and all-feature tests before opening the fork PR. diff --git a/docs/specs/local-provider-endpoint-1.32.md b/docs/specs/local-provider-endpoint-1.32.md new file mode 100644 index 00000000..7dca8082 --- /dev/null +++ b/docs/specs/local-provider-endpoint-1.32.md @@ -0,0 +1,25 @@ +# Local provider endpoint classification (contract 1.32) + +`ClassifyProviderEndpoint2` gives a host the module-owned locality decision used +by `LocalOnly` privacy checks. The request contains only the host component +returned by the host URL parser. TinySecurity performs no DNS lookup and does +not receive the provider URL's path, query, fragment, credentials or payload. + +Only loopback and unspecified IP literals, IPv4-mapped loopback/unspecified +addresses, and `localhost` names (including subdomains) classify as `on_device`. +RFC 1918 addresses, IPv4 and IPv6 link-local addresses, IPv6 unique-local +addresses, and all other names classify as `external`. A private or link-local +address can name another device on the network; `LocalOnly` means the provider +is on this machine, not merely on the same LAN. The classifier does not resolve +names, so DNS aliases cannot turn an external hostname into a local endpoint. +Malformed or unrecognized host literals classify as external. + +Hosts use the result as the `external` fact supplied to `PrivacyCheck2`. If the +classification call fails or the endpoint cannot be parsed, the host must treat +the transfer as external. The method is informational and does not authorize a +network connection or replace transport address validation. + +The existing `Sensitive` privacy decision remains stricter than LocalOnly for +external user content: unassessed user content is denied; only a `none` or +`low` assessment with no identifying categories is allowed. Metadata remains +allowed, while control-plane requests are exempt in every mode. From 9740ed07c93d5b9b4444a2ee3afcdd5767bdb7b0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 11:39:25 +0000 Subject: [PATCH 4/9] fix(egress): correct local provider endpoint classification The local provider was classifying same-machine endpoints using the wrong locality variant, so loopback and link-local addresses could be reported as remote. The classification now uses the correct endpoint type and the associated tests were updated to match. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/provider_endpoint_tests.rs | 2 +- crates/tinysecurity-egress/src/local_provider.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-bus/src/provider_endpoint_tests.rs b/crates/tinysecurity-bus/src/provider_endpoint_tests.rs index 3450908c..d24d7238 100644 --- a/crates/tinysecurity-bus/src/provider_endpoint_tests.rs +++ b/crates/tinysecurity-bus/src/provider_endpoint_tests.rs @@ -40,7 +40,7 @@ fn provider_endpoint_locality_has_stable_wire_names_and_method() { "ClassifyProviderEndpoint2" ); assert!(crate::names::METHODS.contains(&crate::names::methods::CLASSIFY_PROVIDER_ENDPOINT2)); - assert_eq!(CONTRACT_VERSION, (1, 32)); + assert_eq!(crate::CONTRACT_VERSION, (1, 32)); assert!(crate::is_compatible((1, 32))); assert!(!crate::is_compatible((1, 31))); } diff --git a/crates/tinysecurity-egress/src/local_provider.rs b/crates/tinysecurity-egress/src/local_provider.rs index 4657c516..7be30b3b 100644 --- a/crates/tinysecurity-egress/src/local_provider.rs +++ b/crates/tinysecurity-egress/src/local_provider.rs @@ -1,6 +1,6 @@ //! Same-machine local provider endpoint classification for LocalOnly privacy mode. use std::net::{Ipv4Addr, Ipv6Addr}; -use tinysecurity_bus::{ProviderEndpointLocality2, ProviderEndpointRequest2}; +use tinysecurity_bus::ProviderEndpointLocality2; use url::Host; /// Classify a host literal for a local provider endpoint without performing DNS. From ea8d166a4a9e51b2c2c93dec9cf4f14fa1e4dc6a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 11:39:59 +0000 Subject: [PATCH 5/9] fix(egress): classify trailing-dot localhost hosts as on-device Hostnames ending in a dot, such as "localhost." or "worker.localhost.", were not recognised as on-device because the trailing dot was stripped before the suffix check but the rsplit_once branch matched the wrong segment. The classification now strips a single trailing dot and relies on the existing suffix check, and tests cover trailing-dot hosts, IPv4-mapped IPv6 addresses, and the resulting privacy verdicts. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-egress/src/local_provider.rs | 5 +---- .../src/local_provider_tests.rs | 3 +++ .../src/adapter_gap_policy_tests.rs | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-egress/src/local_provider.rs b/crates/tinysecurity-egress/src/local_provider.rs index 7be30b3b..e1abb7dd 100644 --- a/crates/tinysecurity-egress/src/local_provider.rs +++ b/crates/tinysecurity-egress/src/local_provider.rs @@ -18,11 +18,8 @@ pub fn classify_provider_endpoint(host: &str) -> ProviderEndpointLocality2 { Host::Ipv4(address) => local_v4(address), Host::Ipv6(address) => local_v6(address), Host::Domain(name) => { - let name = name.trim_end_matches('.'); + let name = name.strip_suffix('.').unwrap_or(&name); name.eq_ignore_ascii_case("localhost") - || name - .rsplit_once('.') - .is_some_and(|(_, suffix)| suffix.eq_ignore_ascii_case("localhost")) || name.to_ascii_lowercase().ends_with(".localhost") } }; diff --git a/crates/tinysecurity-egress/src/local_provider_tests.rs b/crates/tinysecurity-egress/src/local_provider_tests.rs index 01f765a5..d83ad0f0 100644 --- a/crates/tinysecurity-egress/src/local_provider_tests.rs +++ b/crates/tinysecurity-egress/src/local_provider_tests.rs @@ -6,7 +6,9 @@ use tinysecurity_bus::ProviderEndpointLocality2::{External, OnDevice}; fn classifies_only_same_machine_reserved_provider_hosts_as_on_device() { for host in [ "localhost", + "localhost.", "worker.localhost", + "worker.localhost.", "127.0.0.1", "127.25.3.4", "0.0.0.0", @@ -25,6 +27,7 @@ fn treats_lan_and_non_loopback_reserved_addresses_as_external() { "10.0.0.8", "172.16.0.2", "169.254.1.2", + "[::ffff:192.168.1.5]", "[fe80::1]", "[fd00::1]", "example.local", diff --git a/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs b/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs index ae24bf63..64cd7df3 100644 --- a/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs +++ b/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs @@ -573,6 +573,24 @@ async fn provider_endpoint_classification_is_locality_only_not_lan_reachability( ) .await?; assert_eq!(response.locality, expected, "{host}"); + let mut privacy_request = + privacy(PrivacyMode::LocalOnly, PrivacyDataClass2::UserContent, None); + privacy_request.external = expected == tinysecurity_bus::ProviderEndpointLocality2::External; + let privacy_response: PrivacyCheckResponse2 = call( + &service, + names::methods::PRIVACY_CHECK2, + privacy_request, + ) + .await?; + let expected_verdict = if expected == tinysecurity_bus::ProviderEndpointLocality2::OnDevice + { + Verdict::Allow + } else { + Verdict::Deny { + reason: DenialReason::Privacy, + } + }; + assert_eq!(privacy_response.decision.verdict, expected_verdict, "{host}"); } assert!( call::<_, tinysecurity_bus::ProviderEndpointResponse2>( From b0524cfd0aa67bd3dd6375fac38558ef7355172e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 11:40:34 +0000 Subject: [PATCH 6/9] test(tinysecurity-bus): cover provider endpoint wire shapes Add a test asserting the JSON encoding and decoding of the provider endpoint request and response types, pinning their field names and the on_device locality value so accidental wire format changes are caught. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/provider_endpoint_tests.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/tinysecurity-bus/src/provider_endpoint_tests.rs b/crates/tinysecurity-bus/src/provider_endpoint_tests.rs index d24d7238..3f554180 100644 --- a/crates/tinysecurity-bus/src/provider_endpoint_tests.rs +++ b/crates/tinysecurity-bus/src/provider_endpoint_tests.rs @@ -25,6 +25,29 @@ fn provider_endpoint_request_is_bounded_and_host_only() { ); } +#[test] +fn provider_endpoint_request_and_response_have_stable_wire_shapes() { + let request = ProviderEndpointRequest2 { + host: "localhost".into(), + }; + let encoded = serde_json::to_value(&request).unwrap(); + assert_eq!(encoded, serde_json::json!({"host": "localhost"})); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + request + ); + + let response = ProviderEndpointResponse2 { + locality: ProviderEndpointLocality2::OnDevice, + }; + let encoded = serde_json::to_value(&response).unwrap(); + assert_eq!(encoded, serde_json::json!({"locality": "on_device"})); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + response + ); +} + #[test] fn provider_endpoint_locality_has_stable_wire_names_and_method() { assert_eq!( From 9eb3234e614fd593186e592b9fd695654f764737 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 11:40:42 +0000 Subject: [PATCH 7/9] test(tinysecurity-module): reformat adapter gap policy test assertions Reformatted the provider endpoint locality test to satisfy rustfmt line width limits. The assertions and privacy request setup are unchanged in behaviour, only wrapped across lines. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_gap_policy_tests.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs b/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs index 64cd7df3..00d78859 100644 --- a/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs +++ b/crates/tinysecurity-module/src/adapter_gap_policy_tests.rs @@ -575,13 +575,10 @@ async fn provider_endpoint_classification_is_locality_only_not_lan_reachability( assert_eq!(response.locality, expected, "{host}"); let mut privacy_request = privacy(PrivacyMode::LocalOnly, PrivacyDataClass2::UserContent, None); - privacy_request.external = expected == tinysecurity_bus::ProviderEndpointLocality2::External; - let privacy_response: PrivacyCheckResponse2 = call( - &service, - names::methods::PRIVACY_CHECK2, - privacy_request, - ) - .await?; + privacy_request.external = + expected == tinysecurity_bus::ProviderEndpointLocality2::External; + let privacy_response: PrivacyCheckResponse2 = + call(&service, names::methods::PRIVACY_CHECK2, privacy_request).await?; let expected_verdict = if expected == tinysecurity_bus::ProviderEndpointLocality2::OnDevice { Verdict::Allow @@ -590,7 +587,10 @@ async fn provider_endpoint_classification_is_locality_only_not_lan_reachability( reason: DenialReason::Privacy, } }; - assert_eq!(privacy_response.decision.verdict, expected_verdict, "{host}"); + assert_eq!( + privacy_response.decision.verdict, expected_verdict, + "{host}" + ); } assert!( call::<_, tinysecurity_bus::ProviderEndpointResponse2>( From bfcef7e9330c531e822b642c2883d0965dcf0858 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 11:41:08 +0000 Subject: [PATCH 8/9] test(tinysecurity-bus): return results from provider endpoint tests Convert the provider endpoint wire-shape and locality tests to return `Result` instead of unwrapping, so serialization failures surface as test errors rather than panics. Also wrap `LocalOnly` in backticks in the module and field docs. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-bus/src/provider_endpoint.rs | 4 ++-- .../src/provider_endpoint_tests.rs | 20 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/crates/tinysecurity-bus/src/provider_endpoint.rs b/crates/tinysecurity-bus/src/provider_endpoint.rs index 25dd30a5..b8315800 100644 --- a/crates/tinysecurity-bus/src/provider_endpoint.rs +++ b/crates/tinysecurity-bus/src/provider_endpoint.rs @@ -1,4 +1,4 @@ -//! Host endpoint locality classification for LocalOnly privacy mode (contract 1.32). +//! Host endpoint locality classification for `LocalOnly` privacy mode (contract 1.32). use serde::{Deserialize, Serialize}; /// Maximum size of a parsed URL host supplied for provider locality classification. @@ -47,7 +47,7 @@ pub enum ProviderEndpointLocality2 { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProviderEndpointResponse2 { - /// Locality used by the host's LocalOnly decision. + /// Locality used by the host's `LocalOnly` decision. pub locality: ProviderEndpointLocality2, } diff --git a/crates/tinysecurity-bus/src/provider_endpoint_tests.rs b/crates/tinysecurity-bus/src/provider_endpoint_tests.rs index 3f554180..cf73cf19 100644 --- a/crates/tinysecurity-bus/src/provider_endpoint_tests.rs +++ b/crates/tinysecurity-bus/src/provider_endpoint_tests.rs @@ -26,36 +26,39 @@ fn provider_endpoint_request_is_bounded_and_host_only() { } #[test] -fn provider_endpoint_request_and_response_have_stable_wire_shapes() { +fn provider_endpoint_request_and_response_have_stable_wire_shapes() +-> std::result::Result<(), Box> { let request = ProviderEndpointRequest2 { host: "localhost".into(), }; - let encoded = serde_json::to_value(&request).unwrap(); + let encoded = serde_json::to_value(&request)?; assert_eq!(encoded, serde_json::json!({"host": "localhost"})); assert_eq!( - serde_json::from_value::(encoded).unwrap(), + serde_json::from_value::(encoded)?, request ); let response = ProviderEndpointResponse2 { locality: ProviderEndpointLocality2::OnDevice, }; - let encoded = serde_json::to_value(&response).unwrap(); + let encoded = serde_json::to_value(response)?; assert_eq!(encoded, serde_json::json!({"locality": "on_device"})); assert_eq!( - serde_json::from_value::(encoded).unwrap(), + serde_json::from_value::(encoded)?, response ); + Ok(()) } #[test] -fn provider_endpoint_locality_has_stable_wire_names_and_method() { +fn provider_endpoint_locality_has_stable_wire_names_and_method() +-> std::result::Result<(), Box> { assert_eq!( - serde_json::to_value(ProviderEndpointLocality2::OnDevice).unwrap(), + serde_json::to_value(ProviderEndpointLocality2::OnDevice)?, serde_json::json!("on_device") ); assert_eq!( - serde_json::to_value(ProviderEndpointLocality2::External).unwrap(), + serde_json::to_value(ProviderEndpointLocality2::External)?, serde_json::json!("external") ); assert_eq!( @@ -66,4 +69,5 @@ fn provider_endpoint_locality_has_stable_wire_names_and_method() { assert_eq!(crate::CONTRACT_VERSION, (1, 32)); assert!(crate::is_compatible((1, 32))); assert!(!crate::is_compatible((1, 31))); + Ok(()) } From e627fa763b4532e9b6d6cb368199dede8ce1fcb9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 25 Oct 2026 11:41:29 +0000 Subject: [PATCH 9/9] docs(tinysecurity-egress): format LocalOnly as code in doc comments Wrap `LocalOnly` in backticks throughout the local provider module docs so rustdoc renders it as code. The IPv6 locality check was also rewritten to use `map_or_else`, which is behaviourally identical but avoids the intermediate `Option` closure. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-egress/src/local_provider.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/tinysecurity-egress/src/local_provider.rs b/crates/tinysecurity-egress/src/local_provider.rs index e1abb7dd..cfebde9b 100644 --- a/crates/tinysecurity-egress/src/local_provider.rs +++ b/crates/tinysecurity-egress/src/local_provider.rs @@ -1,4 +1,4 @@ -//! Same-machine local provider endpoint classification for LocalOnly privacy mode. +//! Same-machine local provider endpoint classification for `LocalOnly` privacy mode. use std::net::{Ipv4Addr, Ipv6Addr}; use tinysecurity_bus::ProviderEndpointLocality2; use url::Host; @@ -7,7 +7,7 @@ use url::Host; /// /// Only loopback and unspecified IP literals plus `localhost` names are considered /// on-device. RFC 1918, link-local, IPv6 ULA and other LAN addresses remain external: -/// LocalOnly means that user data does not leave this machine, not merely this LAN. +/// `LocalOnly` means that user data does not leave this machine, not merely this LAN. /// Invalid or unrecognized hosts fail closed as external. #[must_use] pub fn classify_provider_endpoint(host: &str) -> ProviderEndpointLocality2 { @@ -35,10 +35,10 @@ fn local_v4(address: Ipv4Addr) -> bool { } fn local_v6(address: Ipv6Addr) -> bool { - address - .to_ipv4_mapped() - .map(local_v4) - .unwrap_or_else(|| address.is_loopback() || address.is_unspecified()) + address.to_ipv4_mapped().map_or_else( + || address.is_loopback() || address.is_unspecified(), + local_v4, + ) } #[cfg(test)]